ข้อยกเว้นทำงานอย่างไร (เบื้องหลัง) ใน c ++


109

ฉันมักจะเห็นคนพูดว่าข้อยกเว้นนั้นช้า แต่ฉันไม่เคยเห็นข้อพิสูจน์ใด ๆ ดังนั้นแทนที่จะถามว่าพวกเขาเป็นอย่างไรฉันจะถามว่าข้อยกเว้นทำงานอย่างไรในเบื้องหลังดังนั้นฉันจึงสามารถตัดสินใจได้ว่าจะใช้เมื่อใดและจะช้าหรือไม่

จากสิ่งที่ฉันรู้ข้อยกเว้นจะเหมือนกับการส่งคืนหลายครั้งยกเว้นว่าจะตรวจสอบหลังจากการส่งคืนแต่ละครั้งว่าจำเป็นต้องทำอย่างอื่นหรือหยุด จะตรวจสอบได้อย่างไรว่าจะหยุดกลับเมื่อใด ฉันเดาว่ามีกองที่สองที่เก็บประเภทของข้อยกเว้นและตำแหน่งสแต็กจากนั้นจะส่งคืนจนกว่าจะถึงที่นั่น ฉันคาดเดาด้วยว่าครั้งเดียวที่สัมผัสกองที่สองนี้คือการโยนและในการลอง / จับแต่ละครั้ง AFAICT การใช้ลักษณะการทำงานที่คล้ายกันกับรหัสส่งคืนจะใช้เวลาเท่ากัน แต่ทั้งหมดนี้เป็นเพียงการคาดเดาดังนั้นฉันจึงอยากรู้ว่าเกิดอะไรขึ้น

ข้อยกเว้นทำงานอย่างไร



คำตอบ:


105

แทนที่จะเดาฉันตัดสินใจที่จะดูโค้ดที่สร้างขึ้นด้วยโค้ด C ++ ชิ้นเล็ก ๆ และการติดตั้ง Linux ที่ค่อนข้างเก่า

class MyException
{
public:
    MyException() { }
    ~MyException() { }
};

void my_throwing_function(bool throwit)
{
    if (throwit)
        throw MyException();
}

void another_function();
void log(unsigned count);

void my_catching_function()
{
    log(0);
    try
    {
        log(1);
        another_function();
        log(2);
    }
    catch (const MyException& e)
    {
        log(3);
    }
    log(4);
}

ฉันรวบรวมมันด้วยg++ -m32 -W -Wall -O3 -save-temps -cและดูไฟล์ประกอบที่สร้างขึ้น

    .file   "foo.cpp"
    .section    .text._ZN11MyExceptionD1Ev,"axG",@progbits,_ZN11MyExceptionD1Ev,comdat
    .align 2
    .p2align 4,,15
    .weak   _ZN11MyExceptionD1Ev
    .type   _ZN11MyExceptionD1Ev, @function
_ZN11MyExceptionD1Ev:
.LFB7:
    pushl   %ebp
.LCFI0:
    movl    %esp, %ebp
.LCFI1:
    popl    %ebp
    ret
.LFE7:
    .size   _ZN11MyExceptionD1Ev, .-_ZN11MyExceptionD1Ev

_ZN11MyExceptionD1EvคือMyException::~MyException()เพื่อให้คอมไพเลอร์ตัดสินใจว่ามันจำเป็นต้องมีสำเนาที่ไม่ใช่แบบอินไลน์ของเตาเผา

.globl __gxx_personality_v0
.globl _Unwind_Resume
    .text
    .align 2
    .p2align 4,,15
.globl _Z20my_catching_functionv
    .type   _Z20my_catching_functionv, @function
_Z20my_catching_functionv:
.LFB9:
    pushl   %ebp
.LCFI2:
    movl    %esp, %ebp
.LCFI3:
    pushl   %ebx
.LCFI4:
    subl    $20, %esp
.LCFI5:
    movl    $0, (%esp)
.LEHB0:
    call    _Z3logj
.LEHE0:
    movl    $1, (%esp)
.LEHB1:
    call    _Z3logj
    call    _Z16another_functionv
    movl    $2, (%esp)
    call    _Z3logj
.LEHE1:
.L5:
    movl    $4, (%esp)
.LEHB2:
    call    _Z3logj
    addl    $20, %esp
    popl    %ebx
    popl    %ebp
    ret
.L12:
    subl    $1, %edx
    movl    %eax, %ebx
    je  .L16
.L14:
    movl    %ebx, (%esp)
    call    _Unwind_Resume
.LEHE2:
.L16:
.L6:
    movl    %eax, (%esp)
    call    __cxa_begin_catch
    movl    $3, (%esp)
.LEHB3:
    call    _Z3logj
.LEHE3:
    call    __cxa_end_catch
    .p2align 4,,3
    jmp .L5
.L11:
.L8:
    movl    %eax, %ebx
    .p2align 4,,6
    call    __cxa_end_catch
    .p2align 4,,6
    jmp .L14
.LFE9:
    .size   _Z20my_catching_functionv, .-_Z20my_catching_functionv
    .section    .gcc_except_table,"a",@progbits
    .align 4
.LLSDA9:
    .byte   0xff
    .byte   0x0
    .uleb128 .LLSDATT9-.LLSDATTD9
.LLSDATTD9:
    .byte   0x1
    .uleb128 .LLSDACSE9-.LLSDACSB9
.LLSDACSB9:
    .uleb128 .LEHB0-.LFB9
    .uleb128 .LEHE0-.LEHB0
    .uleb128 0x0
    .uleb128 0x0
    .uleb128 .LEHB1-.LFB9
    .uleb128 .LEHE1-.LEHB1
    .uleb128 .L12-.LFB9
    .uleb128 0x1
    .uleb128 .LEHB2-.LFB9
    .uleb128 .LEHE2-.LEHB2
    .uleb128 0x0
    .uleb128 0x0
    .uleb128 .LEHB3-.LFB9
    .uleb128 .LEHE3-.LEHB3
    .uleb128 .L11-.LFB9
    .uleb128 0x0
.LLSDACSE9:
    .byte   0x1
    .byte   0x0
    .align 4
    .long   _ZTI11MyException
.LLSDATT9:

เซอร์ไพรส์! ไม่มีคำแนะนำเพิ่มเติมบนเส้นทางรหัสปกติ คอมไพเลอร์จะสร้างบล็อกโค้ดฟิกซ์อัพเพิ่มเติมนอกบรรทัดแทนโดยอ้างอิงผ่านตารางที่ส่วนท้ายของฟังก์ชัน (ซึ่งจริงๆแล้วจะอยู่ในส่วนที่แยกต่างหากของไฟล์ปฏิบัติการ) งานทั้งหมดจะทำเบื้องหลังโดยไลบรารีมาตรฐานตามตารางเหล่านี้ ( _ZTI11MyExceptionคือtypeinfo for MyException)

ตกลงนั่นไม่ใช่เรื่องแปลกใจสำหรับฉันจริงๆฉันรู้อยู่แล้วว่าคอมไพเลอร์นี้ทำอย่างไร ดำเนินการต่อด้วยเอาต์พุตการประกอบ:

    .text
    .align 2
    .p2align 4,,15
.globl _Z20my_throwing_functionb
    .type   _Z20my_throwing_functionb, @function
_Z20my_throwing_functionb:
.LFB8:
    pushl   %ebp
.LCFI6:
    movl    %esp, %ebp
.LCFI7:
    subl    $24, %esp
.LCFI8:
    cmpb    $0, 8(%ebp)
    jne .L21
    leave
    ret
.L21:
    movl    $1, (%esp)
    call    __cxa_allocate_exception
    movl    $_ZN11MyExceptionD1Ev, 8(%esp)
    movl    $_ZTI11MyException, 4(%esp)
    movl    %eax, (%esp)
    call    __cxa_throw
.LFE8:
    .size   _Z20my_throwing_functionb, .-_Z20my_throwing_functionb

ที่นี่เราจะเห็นรหัสสำหรับการทิ้งข้อยกเว้น แม้ว่าจะไม่มีค่าใช้จ่ายเพิ่มเติมเพียงเพราะอาจมีการโยนข้อยกเว้น แต่ก็มีค่าใช้จ่ายจำนวนมากในการขว้างปาและจับข้อยกเว้น ส่วนใหญ่ซ่อนอยู่ภายใน__cxa_throwซึ่งต้อง:

  • เดินสแต็กด้วยความช่วยเหลือของตารางข้อยกเว้นจนกว่าจะพบตัวจัดการสำหรับข้อยกเว้นนั้น
  • คลายสแต็กจนกว่าจะถึงตัวจัดการนั้น
  • โทรหาตัวจัดการจริงๆ

เปรียบเทียบกับต้นทุนในการส่งคืนมูลค่าและคุณจะเห็นว่าเหตุใดจึงควรใช้ข้อยกเว้นสำหรับผลตอบแทนพิเศษเท่านั้น

ในการเสร็จสิ้นส่วนที่เหลือของไฟล์ประกอบ:

    .weak   _ZTI11MyException
    .section    .rodata._ZTI11MyException,"aG",@progbits,_ZTI11MyException,comdat
    .align 4
    .type   _ZTI11MyException, @object
    .size   _ZTI11MyException, 8
_ZTI11MyException:
    .long   _ZTVN10__cxxabiv117__class_type_infoE+8
    .long   _ZTS11MyException
    .weak   _ZTS11MyException
    .section    .rodata._ZTS11MyException,"aG",@progbits,_ZTS11MyException,comdat
    .type   _ZTS11MyException, @object
    .size   _ZTS11MyException, 14
_ZTS11MyException:
    .string "11MyException"

ข้อมูล typeinfo

    .section    .eh_frame,"a",@progbits
.Lframe1:
    .long   .LECIE1-.LSCIE1
.LSCIE1:
    .long   0x0
    .byte   0x1
    .string "zPL"
    .uleb128 0x1
    .sleb128 -4
    .byte   0x8
    .uleb128 0x6
    .byte   0x0
    .long   __gxx_personality_v0
    .byte   0x0
    .byte   0xc
    .uleb128 0x4
    .uleb128 0x4
    .byte   0x88
    .uleb128 0x1
    .align 4
.LECIE1:
.LSFDE3:
    .long   .LEFDE3-.LASFDE3
.LASFDE3:
    .long   .LASFDE3-.Lframe1
    .long   .LFB9
    .long   .LFE9-.LFB9
    .uleb128 0x4
    .long   .LLSDA9
    .byte   0x4
    .long   .LCFI2-.LFB9
    .byte   0xe
    .uleb128 0x8
    .byte   0x85
    .uleb128 0x2
    .byte   0x4
    .long   .LCFI3-.LCFI2
    .byte   0xd
    .uleb128 0x5
    .byte   0x4
    .long   .LCFI5-.LCFI3
    .byte   0x83
    .uleb128 0x3
    .align 4
.LEFDE3:
.LSFDE5:
    .long   .LEFDE5-.LASFDE5
.LASFDE5:
    .long   .LASFDE5-.Lframe1
    .long   .LFB8
    .long   .LFE8-.LFB8
    .uleb128 0x4
    .long   0x0
    .byte   0x4
    .long   .LCFI6-.LFB8
    .byte   0xe
    .uleb128 0x8
    .byte   0x85
    .uleb128 0x2
    .byte   0x4
    .long   .LCFI7-.LCFI6
    .byte   0xd
    .uleb128 0x5
    .align 4
.LEFDE5:
    .ident  "GCC: (GNU) 4.1.2 (Ubuntu 4.1.2-0ubuntu4)"
    .section    .note.GNU-stack,"",@progbits

ตารางการจัดการข้อยกเว้นและข้อมูลเพิ่มเติมที่หลากหลาย

ดังนั้นข้อสรุปอย่างน้อยสำหรับ GCC บน Linux: ค่าใช้จ่ายเป็นพื้นที่เพิ่มเติม (สำหรับตัวจัดการและตาราง) ไม่ว่าจะมีข้อยกเว้นหรือไม่บวกค่าใช้จ่ายเพิ่มเติมในการแยกวิเคราะห์ตารางและดำเนินการตัวจัดการเมื่อมีข้อยกเว้นเกิดขึ้น หากคุณใช้ข้อยกเว้นแทนรหัสข้อผิดพลาดและข้อผิดพลาดเกิดขึ้นน้อยมากข้อผิดพลาดนั้นอาจเร็วกว่าเนื่องจากคุณไม่มีค่าใช้จ่ายในการทดสอบข้อผิดพลาดอีกต่อไป

ในกรณีที่คุณต้องการข้อมูลเพิ่มเติมโดยเฉพาะอย่างยิ่งสิ่งที่__cxa_ฟังก์ชันทั้งหมดทำดูข้อกำหนดดั้งเดิมที่มาจาก:


23
สรุป ไม่เสียค่าใช้จ่ายหากไม่มีการโยนข้อยกเว้น ค่าใช้จ่ายบางส่วนเมื่อเกิดข้อยกเว้น แต่คำถามคือ 'ค่าใช้จ่ายนี้สูงกว่าการใช้งานและการทดสอบรหัสข้อผิดพลาดตลอดทางกลับไปสู่รหัสการจัดการข้อผิดพลาด'
Martin York

5
ค่าใช้จ่ายผิดพลาดมักจะสูงกว่า รหัสข้อยกเว้นอาจยังอยู่ในดิสก์! เนื่องจากรหัสการจัดการข้อผิดพลาดถูกลบออกจากรหัสปกติลักษณะการทำงานของแคชในกรณีที่ไม่ใช่ข้อผิดพลาดจึงดีขึ้น
MSalters

ในโปรเซสเซอร์บางตัวเช่น ARM การกลับไปยังแอดเดรส "พิเศษ" แปดไบต์ผ่านคำสั่ง "bl" [branch-and-link หรือที่เรียกว่า "call"] จะมีค่าใช้จ่ายเท่ากับการกลับไปยังแอดเดรสทันทีหลังจาก "bl". ฉันสงสัยว่าประสิทธิภาพของการมี "bl" แต่ละตัวตามด้วยที่อยู่ของตัวจัดการ "ข้อยกเว้นขาเข้า" จะเปรียบเทียบกับวิธีการที่ใช้ตารางได้อย่างไรและคอมไพเลอร์ใด ๆ ทำสิ่งนั้นหรือไม่ อันตรายที่ใหญ่ที่สุดที่ฉันเห็นคือการประชุมการโทรที่ไม่ตรงกันอาจทำให้เกิดพฤติกรรมแปลกประหลาด
supercat

2
@supercat: คุณกำลังทำให้ I-cache ของคุณเป็นมลพิษโดยมีข้อยกเว้นในการจัดการรหัสด้วยวิธีนั้น มีเหตุผลที่รหัสการจัดการข้อยกเว้นและตารางมักจะอยู่ห่างไกลจากรหัสปกติในที่สุด
CesarB

1
@CesarB: คำแนะนำหนึ่งคำหลังจากการโทรแต่ละครั้ง ดูเหมือนจะไม่น่ากลัวเกินไปโดยเฉพาะอย่างยิ่งเมื่อได้รับเทคนิคในการจัดการข้อยกเว้นโดยใช้เฉพาะโค้ด "ภายนอก" โดยทั่วไปต้องการให้โค้ดรักษาตัวชี้เฟรมที่ถูกต้องตลอดเวลา (ซึ่งในบางกรณีอาจต้องการคำแนะนำเพิ่มเติม 0 คำสั่ง แต่ในบางกรณีอาจต้องใช้มากกว่า หนึ่ง).
supercat

13

ข้อยกเว้นที่ช้าเป็นเรื่องจริงในสมัยก่อน
ในคอมไพเลอร์สมัยใหม่ส่วนใหญ่สิ่งนี้ไม่ถือเป็นจริงอีกต่อไป

หมายเหตุ: เพียงเพราะเรามีข้อยกเว้นไม่ได้หมายความว่าเราจะไม่ใช้รหัสข้อผิดพลาดด้วย เมื่อสามารถจัดการข้อผิดพลาดได้ให้ใช้รหัสข้อผิดพลาดในเครื่อง เมื่อข้อผิดพลาดต้องการบริบทเพิ่มเติมสำหรับการแก้ไขให้ใช้ข้อยกเว้น: ฉันเขียนไว้อย่างคมคายที่นี่: หลักการใดที่เป็นแนวทางในนโยบายการจัดการข้อยกเว้นของคุณ

ต้นทุนของรหัสการจัดการข้อยกเว้นเมื่อไม่มีการใช้ข้อยกเว้นนั้นแทบจะเป็นศูนย์

เมื่อมีข้อยกเว้นเกิดขึ้นมีงานบางอย่างที่ทำ
แต่คุณต้องเปรียบเทียบสิ่งนี้กับค่าใช้จ่ายในการส่งคืนรหัสข้อผิดพลาดและตรวจสอบตลอดทางกลับไปยังจุดที่สามารถจัดการข้อผิดพลาดได้ ทั้งใช้เวลาเขียนและดูแลรักษานานขึ้น

นอกจากนี้ยังมี gotcha สำหรับมือใหม่:
แม้ว่า Exception object จะมีขนาดเล็ก แต่ก็มีบางคนใส่ของมากมายไว้ข้างใน จากนั้นคุณจะมีค่าใช้จ่ายในการคัดลอกออบเจ็กต์ข้อยกเว้น วิธีแก้ปัญหามีสองเท่า:

  • อย่าใส่สิ่งพิเศษไว้ในข้อยกเว้นของคุณ
  • จับโดยการอ้างอิง const

ในความคิดของฉันฉันจะพนันได้ว่ารหัสเดียวกันที่มีข้อยกเว้นนั้นมีประสิทธิภาพมากกว่าหรืออย่างน้อยก็เทียบได้กับรหัสโดยไม่มีข้อยกเว้น (แต่มีรหัสพิเศษทั้งหมดเพื่อตรวจสอบผลลัพธ์ข้อผิดพลาดของฟังก์ชัน) จำไว้ว่าคุณไม่ได้รับอะไรเลยฟรีคอมไพเลอร์กำลังสร้างโค้ดที่คุณควรเขียนไว้ตั้งแต่แรกเพื่อตรวจสอบรหัสข้อผิดพลาด (และโดยปกติแล้วคอมไพเลอร์จะมีประสิทธิภาพมากกว่ามนุษย์)


1
ฉันจะพนันได้เลยว่าผู้คนลังเลที่จะใช้ข้อยกเว้นไม่ใช่เพราะการรับรู้ช้า แต่เป็นเพราะพวกเขาไม่รู้ว่าพวกเขาใช้งานอย่างไรและทำอะไรกับรหัสของคุณ ความจริงที่ว่าพวกเขาดูเหมือนเวทมนตร์ทำให้เกิดความรำคาญกับประเภทใกล้เคียงกับโลหะมาก
เครื่องบินเร็ว

@speedplane: ฉันคิดว่า แต่จุดรวมของคอมไพเลอร์คือเราไม่จำเป็นต้องเข้าใจฮาร์ดแวร์ (มันมีเลเยอร์นามธรรม) ด้วยคอมไพเลอร์สมัยใหม่ฉันสงสัยว่าคุณจะพบคนเดียวที่เข้าใจทุกแง่มุมของคอมไพเลอร์ C ++ สมัยใหม่หรือไม่ เหตุใดการทำความเข้าใจข้อยกเว้นจึงแตกต่างจากการทำความเข้าใจคุณลักษณะที่ซับซ้อน X
Martin York

คุณต้องมีความคิดเสมอว่าฮาร์ดแวร์กำลังทำอะไรอยู่มันเป็นเรื่องของระดับ หลายคนที่ใช้ C ++ (บน Java หรือภาษาสคริปต์) มักจะทำเช่นนั้นเพื่อประสิทธิภาพ สำหรับพวกเขาเลเยอร์นามธรรมควรมีความโปร่งใสเพื่อให้คุณได้ทราบว่าเกิดอะไรขึ้นในโลหะ
เครื่องบินเร็ว

@speedplane: จากนั้นพวกเขาควรใช้ C โดยที่เลเยอร์นามธรรมนั้นบางกว่ามากตามการออกแบบ
Martin York

12

มีหลายวิธีที่คุณสามารถใช้ข้อยกเว้นได้ แต่โดยทั่วไปแล้วจะอาศัยการสนับสนุนพื้นฐานบางอย่างจากระบบปฏิบัติการ ใน Windows นี่คือกลไกการจัดการข้อยกเว้นที่มีโครงสร้าง

มีการอภิปรายรายละเอียดเกี่ยวกับ Code Project อย่างเหมาะสม: คอมไพเลอร์ C ++ ดำเนินการจัดการข้อยกเว้นอย่างไร

ค่าใช้จ่ายของข้อยกเว้นเกิดขึ้นเนื่องจากคอมไพเลอร์ต้องสร้างโค้ดเพื่อติดตามว่าอ็อบเจ็กต์ใดต้องถูกทำลายในแต่ละสแต็กเฟรม (หรือขอบเขตที่แม่นยำกว่า) หากข้อยกเว้นแพร่กระจายออกจากขอบเขตนั้น หากฟังก์ชันไม่มีตัวแปรโลคัลบนสแต็กที่ต้องการให้เรียกตัวทำลายล้างก็ไม่ควรมีการจัดการข้อยกเว้นข้อยกเว้นโทษประสิทธิภาพการทำงาน

การใช้โค้ดส่งคืนสามารถคลายสแต็กได้ทีละระดับเท่านั้นในขณะที่กลไกการจัดการข้อยกเว้นสามารถข้ามกลับไปยังสแต็กได้มากขึ้นในการดำเนินการเดียวหากไม่มีอะไรให้ทำในเฟรมสแต็กระดับกลาง


"ค่าใช้จ่ายของข้อยกเว้นเกิดขึ้นเนื่องจากคอมไพลเลอร์ต้องสร้างโค้ดเพื่อติดตามว่าอ็อบเจ็กต์ใดต้องถูกทำลายในแต่ละสแต็กเฟรม (หรือขอบเขตที่แม่นยำกว่า)" คอมไพเลอร์ไม่ต้องทำเช่นนั้นเพื่อทำลายอ็อบเจ็กต์จากการส่งคืนหรือไม่?

ไม่ด้วยสแต็กที่มีที่อยู่ส่งคืนและตารางคอมไพเลอร์สามารถกำหนดได้ว่าฟังก์ชันใดอยู่บนสแต็ก จากนั้นวัตถุใดที่ต้องอยู่ในสแต็ก ซึ่งสามารถทำได้หลังจากโยนข้อยกเว้น แพงไปหน่อย แต่จำเป็นก็ต่อเมื่อเกิดข้อยกเว้น
MSalters

เฮฮาฉันแค่สงสัยกับตัวเองว่า "มันจะไม่เจ๋งเลยถ้าแต่ละสแต็กเฟรมติดตามจำนวนวัตถุในนั้นประเภทชื่อเพื่อให้ฟังก์ชันของฉันสามารถขุดสแต็กและดูขอบเขตที่ได้รับมาระหว่างการดีบั๊ก" และในทางหนึ่งสิ่งนี้จะทำเช่นนั้น แต่โดยไม่ต้องประกาศตารางด้วยตนเองเป็นตัวแปรแรกของทุกขอบเขต
Dmitry

6

แมตต์พีเตเร็กเขียนบทความที่ดีในWin32 โครงสร้างการจัดการข้อยกเว้น แม้ว่าบทความนี้จะเขียนขึ้นในปี 1997 แต่ก็ยังมีผลบังคับใช้ในปัจจุบัน (แต่แน่นอนว่าใช้ได้กับ Windows เท่านั้น)


5

บทความนี้ตรวจสอบปัญหาและโดยทั่วไปพบว่าในทางปฏิบัติมีค่าใช้จ่ายรันไทม์สำหรับข้อยกเว้นแม้ว่าค่าใช้จ่ายจะค่อนข้างต่ำหากไม่มีข้อยกเว้น บทความดีๆแนะนำ.



0

คำตอบที่ดีทั้งหมด

นอกจากนี้ลองคิดดูว่าการดีบักโค้ดนั้นง่ายกว่ามากเพียงใดที่ทำ 'if checks' เป็นประตูที่ด้านบนของวิธีการแทนที่จะปล่อยให้รหัสทิ้งข้อยกเว้น

คติประจำใจของฉันคือการเขียนโค้ดที่ใช้งานได้ง่าย สิ่งที่สำคัญที่สุดคือการเขียนโค้ดสำหรับคนต่อไปที่ดูมัน ในบางกรณีเป็นคุณใน 9 เดือนและคุณไม่ต้องการที่จะสาปแช่งชื่อของคุณ!


ฉันเห็นด้วยเหมือนกัน แต่ในบางกรณีข้อยกเว้นอาจทำให้รหัสง่ายขึ้น ลองนึกถึงการจัดการข้อผิดพลาดในตัวสร้าง ... - วิธีอื่น ๆ คือ a) ส่งคืนรหัสข้อผิดพลาดโดยพารามิเตอร์อ้างอิงหรือ b) ตั้งค่าตัวแปรส่วนกลาง
Uhli
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.