ในบล็อกโพสต์นี้ Chris Down วิศวกร Kernel ที่ Meta จะพูดถึงข้อผิดพลาดของการใช้สัญญาณ Linux ในสภาพแวดล้อมการใช้งานจริงของ Linux และเหตุผลที่ผู้พัฒนาควรหลีกเลี่ยงการใช้สัญญาณทุกครั้งหากเป็นไปได้
สัญญาณคือเหตุการณ์ที่ระบบ Linux สร้างขึ้นเพื่อตอบสนองต่อเงื่อนไขบางประการ โดยสัญญาณอาจถูกส่งจาก kernel ไปยังกระบวนการหนึ่ง ถูกส่งจากกระบวนการหนึ่งไปอีกกระบวนการหนึ่ง หรือถูกส่งจากกระบวนการหนึ่งไปยังตัวมันเอง เมื่อได้รับสัญญาณแล้ว กระบวนการก็อาจดำเนินการได้
สัญญาณเป็นส่วนสำคัญของสภาพแวดล้อมการดำเนินงานแบบ Unix และเป็นสิ่งที่มีมาอย่างน้อยก็ตั้งแต่ช่วงแรกเริ่มแล้ว โดยเป็นเหมือนระบบประปาสำหรับองค์ประกอบหลักหลายอย่างของระบบปฏิบัติการ อาทิเช่น การถ่ายโอนข้อมูลหลัก (core dump), การจัดการวงจรชีวิตของกระบวนการ ฯลฯ และโดยทั่วไปแล้วก็ทำหน้าที่ได้ค่อนข้างดีตลอดระยะเวลาห้าสิบกว่าปีที่เราใช้งานมา ด้วยเหตุนี้ เมื่อมีคนชี้ให้เห็นว่าการใช้สัญญาณเพื่อการสื่อสารระหว่างกระบวนการ (IPC) อาจเป็นอันตรายได้จึงทำให้บางคนอาจคิดว่าคำพูดเหล่านี้เป็นเพียงความเห็นเลื่อนลอยของผู้ที่ต้องการคิดค้นวิธีที่ซ้ำซ้อนกับวิธีที่ดีอยู่แล้ว อย่างไรก็ตาม บทความนี้มีวัตถุประสงค์เพื่อสาธิตกรณีที่สัญญาณเป็นสาเหตุของปัญหาการใช้งานจริง และเสนอวิธีบรรเทาผลกระทบที่อาจเกิดขึ้นรวมถึงทางเลือกอื่นๆ
สัญญาณอาจดูน่าสนใจเนื่องจากมีการสร้างมาตรฐาน มีความพร้อมใช้งานที่กว้างขวาง และความจริงที่ว่าสัญญาณเหล่านี้ไม่จำเป็นต้องพึ่งพาสิ่งใดเพิ่มเติมนอกเหนือจากสิ่งที่ระบบปฏิบัติการมีให้ ทว่าการใช้งานให้ปลอดภัยอาจเป็นเรื่องยาก สัญญาณทำให้เกิดสมมติฐานมากมาย ซึ่งเราจะต้องตรวจสอบอย่างระมัดระวังเพื่อให้เป็นไปตามข้อกำหนด หรือไม่อย่างนั้นเราก็ต้องระมัดระวังในการกำหนดค่าให้ถูกต้อง ซึ่งในความเป็นจริง แอพพลิเคชั่นจำนวนมากแม้กระทั่งแอพพลิเคชั่นที่รู้จักกันอย่างแพร่หลายไม่ได้ทำเช่นนั้น และอาจส่งผลให้เกิดเหตุการณ์ที่ยากต่อการแก้ไขข้อบกพร่องได้ในอนาคต
เราจะมาดูเหตุการณ์ล่าสุดที่เกิดขึ้นในสภาพแวดล้อมการใช้งานจริงของ Meta ซึ่งตอกย้ำข้อผิดพลาดของการใช้สัญญาณกัน โดยเราจะพูดถึงประวัติของบางสัญญาณโดยสังเขปและวิธีที่สัญญาณเหล่านี้นำเราไปสู่จุดที่เราอยู่ในปัจจุบัน จากนั้นเราจะนำมาเปรียบเทียบกับความต้องการและปัญหาในปัจจุบันที่เราเห็นในการใช้งานจริง
ก่อนอื่น เรามาเท้าความกันสักนิด ทีม LogDevice ล้างโค้ดเบสของตนทิ้ง โดยเป็นการลบโค้ดและฟีเจอร์ที่ไม่ได้ใช้ออก โดยหนึ่งในฟีเจอร์ที่เลิกใช้งานคือประเภทของบันทึกที่บันทึกการดำเนินงานบางอย่างโดยบริการ ซึ่งสุดท้ายแล้วฟีเจอร์นี้ได้กลายเป็นฟีเจอร์ซ้ำซ้อนที่ไม่มีการลบผู้บริโภคและสิ่งที่กล่าวมาข้างต้นออก ทั้งนี้ คุณสามารถดูการเปลี่ยนแปลงได้ที่นี่บน GitHub ซึ่งยังคงเป็นไปด้วยดีจนถึงตอนนี้
หลังจากการเปลี่ยนแปลงที่ไม่มีอะไรให้พูดถึงมากนักผ่านไปได้ไม่นาน การใช้งานจริงก็ยังคงดำเนินไปอย่างต่อเนื่องและให้บริการแก่ผู้เข้าใช้งานตามปกติ ทว่าไม่กี่สัปดาห์ต่อมา ทีมได้รับรายงานว่าโหนดบริการกำลังสูญหายในอัตราที่น่าตกใจ เหตุการณ์นี้มีความเกี่ยวข้องกับการเปิดตัวรุ่นใหม่ แต่ อะไร คือสิ่งที่ผิดพลาดกลับไม่ชัดเจน สิ่งที่ตอนนี้ต่างไปจากเดิมแล้วทำให้หลายๆ สิ่งพังทลายคืออะไร
ทีมที่ประสบปัญหาได้จำกัดปัญหาให้แคบลงจนถึงการเปลี่ยนแปลงโค้ดที่เราพูดถึงก่อนหน้านี้ โดยเลิกใช้งานบันทึกเหล่านี้ แต่ทำไมล่ะ โค้ดนั้นมีอะไรผิดปกติเหรอ หากคุณไม่ได้ทราบคำตอบอยู่แล้ว เราขอแนะนำให้คุณดูความแตกต่างดังกล่าวและพยายามหาว่ามีอะไรผิดปกติ ซึ่งนี่ไม่ใช่สิ่งที่จะดูออกได้ในทันที และเป็นความผิดพลาดที่ใครๆ ก็สามารถทำได้
Logrotate เป็นเครื่องมือมาตรฐานสำหรับการหมุนเวียนบันทึกเมื่อใช้งาน Linux ซึ่งเครื่องมือนี้มีมาเกือบสามสิบปีแล้ว และมีแนวคิดเรียบง่าย กล่าวคือ จัดการวงจรชีวิตของบันทึกด้วยการหมุนเวียนและลบบันทึกเหล่านั้น
Logrotate ไม่ได้ส่งสัญญาณใดๆ ด้วยตัวเอง ดังนั้นคุณจะไม่พบข้อมูลอะไรเกี่ยวกับสัญญาณเหล่านี้ในหน้าหลักของ Logrotate หรือเอกสารประกอบมากนัก อย่างไรก็ตาม Logrotate สามารถใช้คำสั่งใดก็ได้เพื่อดำเนินการก่อนหรือหลังการหมุนเวียน ดังเช่นตัวอย่างพื้นฐานจากการกำหนดค่าเริ่มต้นของ Logrotate ใน CentOS ซึ่งคุณสามารถดูการกำหนดค่าได้ดังนี้
/var/log/cron /var/log/maillog /var/log/messages /var/log/secure /var/log/spooler { sharedscripts postrotate /bin/kill -HUP `cat /var/run/syslogd.pid 2> /dev/null` 2> /dev/null || true endscript }
ตัวอย่างที่ยกมานี้ค่อนข้างเปราะบาง แต่เราจะละไว้และถือว่าการกำหนดค่านี้ใช้งานได้ตามที่ตั้งใจไว้ การกำหนดค่านี้บอกว่าหลังจาก Logrotate หมุนเวียนไฟล์ใดก็ตามที่อยู่ในรายการ Logrotate ควรส่ง SIGHUP
ไปยัง PID ที่มีอยู่ใน /var/run/syslogd.pid
ซึ่งควรเป็นของอินสแตนซ์ syslogd
ที่ทำงานอยู่
การกำหนดค่านี้ดีสำหรับเครื่องมือที่มี API สาธารณะที่เสถียร เช่น syslog แต่กับเครื่องมือภายในที่การใช้งาน SIGHUP
เป็นรายละเอียดการใช้งานภายในที่สามารถเปลี่ยนแปลงได้ตลอดเวลาล่ะ
ปัญหาอย่างหนึ่งที่เกิดขึ้นก็คือ ความหมายเชิงนัยของสัญญาณนั้นขึ้นอยู่กับการตีความและตั้งโปรแกรมของผู้พัฒนาแอพพลิเคชั่นและผู้ใช้ ยกเว้นสัญญาณที่ไม่สามารถตรวจจับได้ในพื้นที่ของผู้ใช้และมีความหมายเดียว เช่น SIGKILL
และ SIGSTOP
ในบางกรณี ความแตกต่างส่วนใหญ่เป็นไปในทางทฤษฎี เช่น SIGTERM
ซึ่งเข้าใจกันโดยทั่วไปว่าหมายถึง "ยุติอย่างสง่างามโดยเร็วที่สุด" อย่างไรก็ตาม ในกรณีของ SIGHUP
ความหมายจะมีความชัดเจนน้อยกว่ากันมาก
SIGHUP
ถูกคิดค้นขึ้นมาสำหรับการสื่อสารแบบอนุกรม (serial lines) และเดิมทีใช้เพื่อระบุว่าปลายทางอีกด้านหนึ่งของการเชื่อมต่อได้มีการส่งข้อมูลมา หากเปรียบเทียบกับการที่เรายังคงสืบสายเลือดของเรามาจนถึงทุกวันนี้ SIGHUP
ก็ยังคงถูกส่งไปยังที่ซึ่งเทอร์มินัลเทียม (pseudo terminal) หรือเทอร์มินัลเสมือนถูกปิด (จึงมีการใช้เครื่องมืออย่าง nohup
ที่ทำการ mask)
ในยุคแรกๆ ของ Unix มีความจำเป็นต้องทำการรีโหลด daemon ซึ่งโดยปกติแล้ว อย่างน้อยๆ จะประกอบด้วยการกำหนดค่า/ไฟล์บันทึกที่เปิดใหม่โดยไม่ต้องรีสตาร์ท และสัญญาณดูเหมือนจะเป็นวิธีที่ปราศจากการพึ่งพาเพื่อให้บรรลุเป้าหมายดังกล่าว แน่นอนว่าไม่มีสัญญาณสำหรับอะไรแบบนั้น แต่เนื่องจาก daemon เหล่านี้ไม่มีเทอร์มินัลการควบคุม จึงไม่มีเหตุผลที่จะรับ SIGHUP
ดังนั้นสัญญาณนี้จึงดูเหมือนจะเป็นสัญญาณที่สะดวกต่อการแอบเข้าถึงโดยไม่มีผลข้างเคียงใดๆ ที่ชัดเจน
แต่ถึงอย่างนั้นแผนนี้ก็มีปัญหาอยู่เล็กน้อย โดยสถานะเริ่มต้นสำหรับสัญญาณไม่ได้เป็นแบบ "ละเว้น" แต่เป็นแบบเฉพาะสัญญาณ ตัวอย่างเช่น โปรแกรมไม่จำเป็นต้องกำหนดค่า SIGTERM
ด้วยตนเองเพื่อยุติแอพพลิเคชั่นของตน ตราบใดที่ไม่ได้มีการตั้งค่าตัวจัดการสัญญาณอื่นๆ Kernel ก็จะยุติโปรแกรมของพวกเขาฟรีโดยไม่จำเป็นต้องมีโค้ดใดๆ ในพื้นที่ผู้ใช้ ซึ่งนับว่าสะดวกมาก
แต่ถึงอย่างนั้นก็มีความไม่สะดวกอยู่ กล่าวคือ SIGHUP
มีการยุติโปรแกรมในทันทีเป็นลักษณะการทำงานเริ่มต้นด้วย วิธีนี้ใช้ได้ผลดีกับกรณีการหยุดชะงักแบบดั้งเดิมซึ่งไม่จำเป็นต้องใช้แอพพลิเคชั่นเหล่านี้อีกต่อไป แต่ได้ผลไม่ดีเท่าใดนักกับความหมายใหม่นี้
แน่นอนว่ามันจะเป็นการดีถ้าหากเราลบทุกที่ที่อาจส่ง SIGHUP
ไปยังโปรแกรมออกไปให้หมด ซึ่งปัญหามีอยู่ว่าในโค้ดเบสขนาดใหญ่ที่เติบโตเต็มที่นั้นลบได้ยาก SIGHUP
ไม่เหมือนกับการเรียก IPC ที่ควบคุมอย่างเข้มงวดซึ่งคุณสามารถใช้คำสั่ง grep โค้ดเบสได้อย่างง่ายดาย สัญญาณนั้นมาได้จากทุกที่ ทุกเวลา และมีการตรวจสอบการทำงานของสัญญาณเพียงเล็กน้อย (นอกเหนือจากการตรวจสอบขั้นพื้นฐานที่สุดอย่าง "คุณคือผู้ใช้รายนี้หรือมี CAP_KILL
") ประเด็นสำคัญที่สุดก็คือ มันยากที่จะระบุได้ว่าสัญญาณจะมาจากไหนได้บ้าง แต่ IPC ที่ชัดเจนกว่านี้จะทำให้เรารู้ว่าสัญญาณนี้ไม่ได้มีความหมายอะไรสำหรับเราและควรได้รับการเพิกเฉย
ถึงตอนนี้ ผมคิดว่าคุณน่าจะเริ่มเดาได้แล้วว่าเกิดอะไรขึ้น การเปิดตัว LogDevice เริ่มต้นในบ่ายวันหนึ่งที่โชคชะตาฟ้าลิขิต โดยมีการเปลี่ยนแปลงโค้ดตามที่ได้พูดถึงไปแล้วก่อนหน้านี้ ในตอนแรกไม่มีอะไรผิดปกติ แต่พอถึงเที่ยงคืนของวันรุ่งขึ้น ทุกสิ่งทุกอย่างก็เริ่มพังทลายลงอย่างลึกลับ โดยสาเหตุก็คือชุดคำสั่งต่อไปนี้ในการกำหนดค่า Logrotate ของแมชชีนซึ่งส่ง SIGHUP
ที่ไม่สามารถจัดการได้ในขณะนี้ (และส่งผลร้ายแรง) ไปยัง daemon ของ LogDevice
/var/log/logdevice/audit.log { daily # [...] postrotate pkill -HUP logdeviced endscript }
ซึ่งการที่ชุดคำสั่งสั้นๆ ของการกำหนดค่า Logrotate หายไปเพียงชุดเดียวนั้นเกิดขึ้นได้ง่ายและพบได้บ่อยอย่างเหลือเชื่อเมื่อลบฟีเจอร์ขนาดใหญ่ออก อีกทั้งยังเป็นการยากที่จะแน่ใจได้ว่าร่องรอยการมีอยู่ของการกำหนดค่าถูกลบออกในคราวเดียว แม้แต่ในกรณีที่ตรวจสอบได้ง่ายกว่านี้ ก็มักจะมีการทิ้งร่องรอยที่เหลือไว้โดยไม่ได้ตั้งใจเมื่อทำการล้างโค้ดเป็นเรื่องปกติ แต่โดยปกติแล้ว มันจะไม่สร้างความเสียหายใดๆ ตามมา เนื่องจากเศษซากที่เหลือนั้นเป็นเพียงโค้ดที่ตายแล้วหรือที่ไม่มีการทำงานใดๆ
โดยแนวคิดแล้ว ตัวเหตุการณ์และการแก้ไขนั้นเรียบง่าย คือ อย่าส่ง SIGHUP
และเผยแพร่การดำเนินการของ LogDevice ให้มากขึ้นเมื่อเวลาผ่านไป (กล่าวคือ อย่าเผยแพร่ตอนเที่ยงคืน) อย่างไรก็ตาม ไม่ได้มีแค่รายละเอียดของเหตุการณ์นี้เพียงอย่างเดียวที่เราควรให้ความสำคัญ แต่เหนือสิ่งอื่นใดแล้ว เหตุการณ์นี้ต้องทำหน้าที่เป็นเครื่องมือในการส่งเสริมไม่ให้ใช้สัญญาณในการใช้งานจริงสำหรับกรณีอื่นๆ นอกเหนือจากกรณีพื้นฐานและมีความจำเป็นที่สุด
อย่างแรก การใช้สัญญาณเป็นกลไกที่ส่งผลต่อการเปลี่ยนแปลงในสถานะกระบวนการของระบบปฏิบัติการ เป็น รากฐานที่ดี ซึ่งประกอบด้วยสัญญาณอย่าง SIGKILL
ซึ่งเป็นไปไม่ได้ที่จะติดตั้งตัวจัดการสัญญาณนี้ แล้วทำสิ่งที่คุณคาดหวังทุกประการ และลักษณะการทำงานเริ่มต้นของ Kernel ของ SIGABRT
, SIGTERM
, SIGINT
, SIGSEGV
และ SIGQUIT
รวมถึงสัญญาณอื่นๆ ที่คล้ายกัน ซึ่งโดยทั่วไปแล้วผู้ใช้และโปรแกรมเมอร์จะเข้าใจเป็นอย่างดี
สิ่งที่สัญญาณเหล่านี้มีเหมือนกันก็คือ เมื่อคุณได้รับสัญญาณแล้ว สัญญาณทั้งหมดจะเข้าสู่สถานะของเทอร์มินัลปลายทางภายใน Kernel เอง กล่าวคือ จะไม่มีการดำเนินการตามขั้นตอนของพื้นที่ผู้ใช้อีกต่อไปเมื่อคุณได้รับ SIGKILL
หรือ SIGTERM
ที่ไม่มีตัวจัดการสัญญาณพื้นที่ผู้ใช้
สถานะของเทอร์มินัลปลายทางมีความสำคัญ เนื่องจากโดยปกติแล้วสถานะนี้หมายความว่าคุณกำลังพยายามลดความซับซ้อนของสแต็กและโค้ดที่กำลังดำเนินการอยู่ ส่วนสถานะอื่นๆ ที่ต้องการมักจะส่งผลให้มีความซับซ้อนมากขึ้นและให้เหตุผลได้ยากขึ้น เนื่องจากลำดับขั้นตอนและการทำงานของโค้ดที่เกิดขึ้นพร้อมๆ กันมีความยุ่งเหยิงมากขึ้น
คุณอาจสังเกตเห็นว่าเราไม่ได้พูดถึงสัญญาณอื่นๆ บางสัญญาณที่ยุติการใช้งานโดยค่าเริ่มต้นเหมือนกัน ต่อไปนี้คือรายชื่อสัญญาณมาตรฐานทั้งหมดที่ยุติการใช้งานโดยค่าเริ่มต้น (ยกเว้นสัญญาณการถ่ายโอนข้อมูลหลัก (core dump) อย่าง SIGABRT
หรือ SIGSEGV
เนื่องจากสัญญาณเหล่านี้สมเหตุสมผลทั้งหมด):
เมื่อมองแวบแรก สัญญาณเหล่านี้อาจดูสมเหตุสมผล แต่ขอให้ดูความผิดปกติต่อไปนี้
และที่กล่าวมาข้างต้นคือสัญญาณเทอร์มินัลเกือบหนึ่งในสามที่เป็นปัญหาที่สุด และที่แย่ที่สุดคืออันตรายเป็นอย่างยิ่งเมื่อความต้องการของโปรแกรมเปลี่ยนไป ที่แย่ไปกว่านั้น แม้แต่สัญญาณที่ "ผู้ใช้กำหนด" ที่คาดคะเนไว้ก็เป็นหายนะที่รอวันเกิดเมื่อมีคนลืม SIG_IGN
สัญญาณอย่างชัดแจ้ง หรือแม้กระทั่ง SIGUSR1
หรือ SIGPOLL
ที่ไม่มีพิษภัยก็อาจทำให้เกิดปัญหาได้
นี่ไม่ใช่แค่คำถามเกี่ยวกับความคุ้นเคย ไม่ว่าคุณจะรู้วิธีการทำงานของสัญญาณดีแค่ไหน การเขียนโค้ดที่สัญญาณถูกต้องในครั้งแรกก็ยังเป็นเรื่องยากมากอยู่ดี เพราะแม้ว่าสัญญาณจะมีหน้าตาที่ชัดเจน แต่ก็มีความซับซ้อนกว่าที่เห็นมาก
โดยทั่วไปแล้ว โปรแกรมเมอร์จะไม่ใช้เวลาทั้งวันคิดเกี่ยวกับการทำงานภายในของสัญญาณ ซึ่งหมายความว่าเมื่อจำเป็นต้องใช้การจัดการสัญญาณจริงๆ โปรแกรมเมอร์มักจะทำสิ่งที่ผิดพลาดซึ่งดูออกได้ยาก
ผมไม่ได้พูดถึงกรณี "เล็กน้อย" เช่น ความปลอดภัยในฟังก์ชั่นการจัดการสัญญาณ ซึ่งส่วนใหญ่แก้ไขได้ด้วยการบัมพ์ sig_atomic_t
หรือใช้รั้วสัญญาณแบบครบหน่วย (atomic signal fence) ของ C++ ด้วยซ้ำ ซึ่งส่วนใหญ่ค้นหาได้ง่ายและน่าจดจำในฐานะข้อผิดพลาดของใครก็ตามที่เพิ่งผ่านการจัดการสัญญาณครั้งแรกซึ่งมีความยุ่งยาก แต่สิ่งที่ยากกว่ากันมากคือการให้เหตุผลเกี่ยวกับลำดับขั้นตอนของโค้ดในส่วนเล็กๆ ของโปรแกรมที่ซับซ้อนเมื่อได้รับสัญญาณ การทำเช่นนี้ต้องมีการคิดอย่างต่อเนื่องและชัดเจนเกี่ยวกับสัญญาณในทุกส่วนของวงจรชีวิตแอพพลิเคชั่น (เดี๋ยวก่อน แล้ว EINTR
ล่ะ SA_RESTART
เพียงพอหรือเปล่า เราควรดูลำดับขั้นตอนอะไรถ้าสัญญาณนี้ยุติการใช้งานก่อนเวลาอันควร ตอนนี้ฉันมีโปรแกรมที่ทำงานพร้อมกัน ความหมายของมันคืออะไร) หรือการตั้งค่า sigprocmask
หรือ pthread_setmask
สำหรับบางส่วนของวงจรชีวิตแอพพลิเคชั่นของคุณแล้วภาวนาให้ลำดับขั้นตอนของโค้ดไม่มีการเปลี่ยนแปลง (ซึ่งแน่นอนว่าไม่ใช่การคาดเดาที่ดีในบรรยากาศของการพัฒนาที่รวดเร็ว) signalfd
หรือการรัน sigwaitinfo
ในเธรดเฉพาะอาจจะพอช่วยได้บ้าง แต่ทั้งสองสัญญาณนี้มีกรณีที่เกิดข้อผิดพลาดและข้อกังวลด้านการใช้งานที่เยอะมากจนยากที่จะแนะนำให้ใช้
เราเชื่อว่าโปรแกรมเมอร์ที่มีประสบการณ์ส่วนใหญ่คงรู้อยู่แล้วว่าแม้แต่ตัวอย่างคร่าวๆ ของการเขียนโค้ดที่ปลอดภัยต่อเธรดอย่างถูกต้องก็เป็นเรื่องที่ยากมาก และถ้าคุณคิดว่าการเขียนโค้ดที่ปลอดภัยต่อเธรดอย่างถูกต้องเป็นเรื่องยาก สัญญาณก็ยิ่งยากกว่ากันมาก ตัวจัดการสัญญาณต้องอาศัยโค้ดที่ไม่มีการล็อกอย่างเคร่งครัดกับโครงสร้างข้อมูลแบบครบหน่วยเท่านั้น ทั้งนี้เนื่องจากลำดับขั้นตอนหลักของการดำเนินการถูกระงับและเราไม่รู้ว่ามันถือตัวล็อกใดอยู่ และเนื่องจากลำดับขั้นตอนหลักของการดำเนินการอาจกำลังปฏิบัติการแบบไม่ครบหน่วย (non-atomic operations) ตามลำดับ นอกจากนี้ตัวจัดการสัญญาณต้องกลับเข้ามาใหม่อย่างเต็มรูปแบบ กล่าวคือ จะต้องสามารถซ้อนอยู่ภายในตัวเองได้ เนื่องจากตัวจัดการสัญญาณสามารถทับซ้อนกันได้หากมีการส่งสัญญาณหลายครั้ง (หรือแม้แต่สัญญาณเดียวด้วย SA_NODEFER
) ซึ่งเป็นสาเหตุหนึ่งที่ทำให้คุณไม่สามารถใช้ฟังก์ชั่นต่างๆ เช่น printf
หรือ malloc
ในตัวจัดการสัญญาณได้ เนื่องจากฟังก์ชั่นเหล่านี้พึ่งพา global mutex ในการซิงค์ หากคุณกำลังถือตัวล็อกดังกล่าวไว้เมื่อได้รับสัญญาณ แล้วเรียกใช้ฟังก์ชั่นที่จำเป็นต้องมีตัวล็อกนั้นอีกครั้ง แอพพลิเคชั่นของคุณจะหยุดชะงัก ซึ่งเป็นเรื่องที่อธิบายได้ยากจริงๆ ด้วยเหตุนี้ หลายคนจึงเขียนคำสั่งต่อไปนี้ให้เป็นตัวจัดการสัญญาณของตัวเอง
static volatile sig_atomic_t received_sighup; static void sighup(int sig __attribute__((unused))) { received_sighup = 1; } static int configure_signal_handlers(void) { return sigaction( SIGHUP, &(const struct sigaction){.sa_handler = sighup, .sa_flags = SA_RESTART}, NULL); } int main(int argc, char *argv[]) { if (configure_signal_handlers()) { /* failed to set handlers */ } /* usual program flow */ if (received_sighup) { /* reload */ received_sighup = 0; } /* usual program flow */ }
ปัญหาคือแม้ว่า signalfd
หรือความพยายามอื่นๆ ในการจัดการสัญญาณแบบไม่ซิงค์อาจดูค่อนข้างเรียบง่ายและมีประสิทธิภาพ แต่คำสั่งนี้ละเลยความจริงที่ว่าจุดหยุดชะงักมีความสำคัญพอๆ กับการดำเนินการที่กระทำหลังจากได้รับสัญญาณ ตัวอย่างเช่น สมมติว่าโค้ดพื้นที่ผู้ใช้ของคุณกำลังทำ I/O หรือเปลี่ยนเมตาดาต้าของอ็อบเจ็กต์ที่มาจาก Kernel (เช่น Inode หรือ FD) ในกรณีนี้ จริงๆ แล้วคุณอาจอยู่ในสแต็กพื้นที่ของ Kernel ในขณะที่เกิดการหยุดชะงัก ตัวอย่างต่อไปนี้แสดงให้เห็นลักษณะของเธรดที่อาจเป็นไปได้เมื่อพยายามปิดตัวอธิบายไฟล์
# cat /proc/2965230/stack [<0>] schedule+0x43/0xd0 [<0>] io_schedule+0x12/0x40 [<0>] wait_on_page_bit+0x139/0x230 [<0>] filemap_write_and_wait+0x5a/0x90 [<0>] filp_close+0x32/0x70 [<0>] __x64_sys_close+0x1e/0x50 [<0>] do_syscall_64+0x4e/0x140 [<0>] entry_SYSCALL_64_after_hwframe+0x44/0xa9
คุณจะเห็นว่า __x64_sys_close
คือตัวแปร x86_64 ของการเรียกใช้ระบบ close
ซึ่งปิดตัวอธิบายไฟล์ เมื่อการดำเนินการมาถึงจุดนี้ เราจะรอให้พื้นที่เก็บข้อมูลสำรองได้รับการอัปเดต (ซึ่งก็คือคำสั่ง wait_on_page_bit
นี้) เนื่องจากการทำงานของ I/O มักจะช้ากว่าการดำเนินการอื่นๆ หลายลำดับ schedule
นี้จึงเป็นวิธีบอกใบ้ให้กับตัวกำหนดเวลา CPU ของ Kernel โดยสมัครใจว่าเรากำลังจะดำเนินการที่มีเวลาแฝงสูง (เช่น ดิสก์หรือเครือข่าย I/O) และควรพิจารณาหากระบวนการอื่นในการกำหนดเวลาแทนกระบวนการปัจจุบันไปก่อน ซึ่งนับเป็นวิธีที่ดี เนื่องจากช่วยให้เราสามารถส่งสัญญาณไปยัง Kernel ว่าควรทำการเลือกกระบวนการที่จะใช้ CPU ให้เป็นประโยชน์จริงๆ แทนที่จะเสียเวลากับกระบวนการที่ไม่สามารถดำเนินการต่อได้จนกว่าจะเสร็จสิ้นการรอการตอบสนองจากสิ่งที่อาจต้องใช้เวลาสักพัก
ให้ลองนึกภาพว่าเราส่งสัญญาณไปยังกระบวนการที่เรากำลังใช้งานอยู่ สัญญาณที่เราส่งไปมีตัวจัดการพื้นที่ผู้ใช้ในเธรดที่จะรับสัญญาณดังกล่าว ดังนั้นเราจะดำเนินการต่อในพื้นที่ผู้ใช้ วิธีหนึ่งที่การแข่งขันนี้จะสามารถจบลงได้ก็คือ Kernel จะพยายามออกจาก schedule
คลายสแต็กเพิ่มเติมและส่งคืน errno ของ ESYSRESTART
หรือ EINTR
ไปยังพื้นที่ผู้ใช้เพื่อระบุว่าเราถูกขัดจังหวะ แต่เราปิดได้มากแค่ไหน สถานะของตัวอธิบายไฟล์ในตอนนี้เป็นอย่างไร
เนื่องจากเรากลับมายังพื้นที่ผู้ใช้แล้ว เราก็จะใช้งานตัวจัดการสัญญาณ เมื่อตัวจัดการสัญญาณออกจากระบบ เราจะเผยแพร่ข้อผิดพลาดไปยังตัวกำหนดขอบเขต close
ของ libc พื้นที่ผู้ใช้ จากนั้นเผยแพร่ไปยังแอพพลิเคชั่น ซึ่งในทางทฤษฎีแล้วสามารถดำเนินการบางอย่างเกี่ยวกับสถานการณ์ที่พบได้ ที่เราพูดว่า "ในทางทฤษฎี" ก็เพราะเป็นเรื่องยากมากที่จะรู้ว่าเราต้องทำอะไรกับหลายๆ สถานการณ์เหล่านี้โดยใช้สัญญาณ และบริการจำนวนมากในการใช้งานจริงไม่สามารถจัดการกับกรณีที่เกิดข้อผิดพลาดได้ดีเท่าใดนัก วิธีดังกล่าวอาจใช้ได้ในบางแอพพลิเคชั่นที่ความสมบูรณ์ของข้อมูลไม่สำคัญขนาดนั้น อย่างไรก็ตาม ในแอพพลิเคชั่นการใช้งานจริงที่ใส่ใจ อย่างจริงจัง ในเรื่องของความสอดคล้องและความสมบูรณ์ของข้อมูล วิธีนี้ทำให้เห็นปัญหาที่สำคัญ กล่าวคือ Kernel ไม่ได้เปิดเผยวิธีอย่างละเอียดใดๆ ที่จะทำให้เข้าใจว่ามันไปได้ไกลแค่ไหน มีอะไรบ้างที่ทำสำเร็จและไม่สำเร็จ และอะไรคือสิ่งที่เราควรทำจริงๆ เกี่ยวกับสถานการณ์นั้นๆ ที่แย่ไปกว่านั้น ถ้าหากคำสั่ง close
กลับมาพร้อมกับคำสั่ง EINTR
สถานะของตัวอธิบายไฟล์จะไม่ได้รับการระบุ ดังนี้
“หากคำสั่ง close() ถูกสัญญาณ [...] ขัดจังหวะ สถานะของ [ตัวอธิบายไฟล์] จะไม่ได้รับการระบุ”
ขอให้โชคดีกับการพยายามให้เหตุผลเกี่ยวกับวิธีจัดการคำสั่งดังกล่าวอย่างปลอดภัยในแอพพลิเคชั่นของคุณ โดยทั่วไปแล้ว การจัดการ EINTR
แม้กระทั่งกับการเรียกใช้ระบบที่เป็นไปด้วยดีก็มีความซับซ้อน มีปัญหาเล็กๆ น้อยๆ จำนวนมากเป็นสาเหตุหลักที่ทำให้คำสั่ง SA_RESTART
ไม่เพียงพอ ทั้งนี้ การเรียกใช้ระบบไม่ได้รีสตาร์ทได้เหมือนกันทั้งหมด และคาดหวังว่าผู้พัฒนาแอพพลิเคชั่นของคุณทุกคนจะเข้าใจและลดความแตกต่างเชิงลึกของการรับสัญญาณสำหรับทุกการเรียกใช้ระบบ ณ จุดการเรียกใช้ทุกจุดที่ขอให้มีการหยุดทำงานได้ ต่อไปนี้คือข้อความจาก man 7 signal
“อินเทอร์เฟซต่อไปนี้จะไม่รีสตาร์ทหลังจากถูกตัวจัดการสัญญาณขัดจังหวะ โดยไม่คำนึงถึงการใช้งาน SA_RESTART ซึ่งมักจะล้มเหลวโดยมีข้อผิดพลาด EINTR [...]”
ในทำนองเดียวกัน การใช้ sigprocmask
และการคาดหวังว่าลำดับขั้นตอนของโค้ดจะยังคงที่ก็ทำให้เกิดปัญหา เนื่องจากโดยปกติแล้ว ผู้พัฒนามักจะไม่เอาเวลามาคิดเกี่ยวกับขอบเขตของการจัดการสัญญาณ หรือคิดหาวิธีสร้างหรือรักษาโค้ดที่สัญญาณถูกต้อง เช่นเดียวกับการจัดการสัญญาณในเธรดเฉพาะที่มี sigwaitinfo
ซึ่งอาจต้องจบด้วย GDB และเครื่องมือคล้ายกันที่ไม่สามารถแก้ไขจุดบกพร่องของกระบวนการได้ ลำดับขั้นตอนของโค้ดที่ผิดพลาดเล็กๆ น้อยๆ หรือการจัดการข้อผิดพลาดอาจส่งผลให้เกิดจุดบกพร่อง เกิดการหยุดทำงาน แก้ไขจุดที่เสียหายได้ยาก เกิดการหยุดชะงัก และปัญหาอื่นๆ อีกมากมายที่จะนำคุณไปสู่การใช้เครื่องมือจัดการเหตุการณ์ที่คุณต้องการ
หากคุณคิดว่าการทำงานพร้อมกัน การกลับเข้ามาใหม่ และการใช้สัญญาณแบบครบหน่วยที่ได้พูดถึงกันไปนั้นแย่มากพอแล้ว การรวมการประมวลผลแบบมัลติเธรดเข้ามาด้วยยิ่งทำให้สิ่งต่างๆ ซับซ้อนขึ้นไปอีก เรื่องนี้มีความสำคัญอย่างยิ่งเมื่อพิจารณาถึงข้อเท็จจริงที่ว่าแอพพลิเคชั่นที่ซับซ้อนจำนวนมากใช้งานเธรดแยกกันอย่างชัดเจน ตัวอย่างเช่น ใช้งานเธรดเป็นส่วนหนึ่งของ jemalloc, GLib หรือคล้ายกัน ไลบรารีเหล่านี้บางรายการถึงขั้นติดตั้งตัวจัดการสัญญาณด้วยตัวเอง ซึ่งส่งผลให้เกิดความซับซ้อนยิ่งขึ้น
man 7 signal
ได้กล่าวถึงเรื่องนี้ไว้ดังนี้
“สัญญาณอาจถูกสร้างขึ้น (และกำลังรอดำเนินการ) สำหรับเธรดทั้งหมด (เช่น เมื่อส่งโดยใช้ kill(2)) หรือสำหรับเธรดใดเธรดหนึ่ง [...] หากมีเธรดที่สัญญาณไม่ถูกบล็อกมากกว่าหนึ่งเธรดขึ้นไป Kernel จะเลือกเธรดที่ต้องการเพื่อส่งสัญญาณ”
หรือกล่าวให้ชัดเจนขึ้นก็คือ "สำหรับสัญญาณส่วนใหญ่แล้ว Kernel จะส่งสัญญาณไปยังเธรดใดก็ตามที่ไม่ได้บล็อกสัญญาณด้วย sigprocmask
" SIGSEGV, SIGILL และคำสั่งที่คล้ายกันทำหน้าที่คล้ายกับดัก และให้สัญญาณนั้นๆ มุ่งเป้าไปที่เธรดที่ละเมิดอย่างชัดเจน แต่ถึงแม้ใครจะคิดอย่างไรก็ตาม สัญญาณส่วนใหญ่ก็ไม่สามารถส่งสัญญาณไปยังเธรดเดียวในกลุ่มเธรดได้อย่างชัดเจน ไม่เว้นแม้กระทั่งการใช้ tgkill
หรือ pthread_kill
นั่นหมายความว่าคุณไม่สามารถเปลี่ยนลักษณะของการจัดการสัญญาณโดยรวมแม้เพียงเล็กน้อยทันทีที่คุณมีชุดของเธรด หากบริการจำเป็นต้องบล็อกสัญญาณเป็นระยะด้วย sigprocmask
ในเธรดหลัก คุณก็จำเป็นต้องสื่อสารกับเธรดอื่นๆ เป็นการภายนอกว่าเธรดเหล่านั้นควรจัดการการบล็อกสัญญาณอย่างไร มิฉะนั้นสัญญาณอาจถูกเธรดอื่นกลืน และจะไม่มีใครเห็นสัญญาณนั้นอีกเลย แน่นอนว่าคุณสามารถบล็อกสัญญาณในเธรดย่อยเพื่อหลีกเลี่ยงเหตุการณ์นี้ได้ แต่ถ้าหากเธรดเหล่านี้จำเป็นต้องจัดการสัญญาณของตัวเอง แม้แต่กับสัญญาณพื้นฐานอย่าง waitpid
ก็จะทำให้สิ่งต่างๆ เกิดความซับซ้อน
สิ่งเหล่านี้ไม่ใช่ปัญหาทางเทคนิคที่ยากเกินกว่าจะก้าวข้าม เช่นเดียวกับกรณีอื่นๆ ที่ได้กล่าวไปแล้ว อย่างไรก็ตาม อาจมีคนมองข้ามความจริงที่ว่าความซับซ้อนของการซิงค์ที่จำเป็นในการทำให้งานนี้ถูกต้องนั้นเป็นภาระหนัก และเป็นสิ่งที่ทำให้เกิดจุดบกพร่อง เกิดความสับสน หรืออาจแย่กว่านั้น
สัญญาณต่างๆ ได้รับการเผยแพร่แบบไม่ซิงค์กันใน Kernel การเรียกใช้ระบบด้วยคำสั่ง kill
จะกลับมาทันทีที่มีการบันทึกสัญญาณที่รอดำเนินการสำหรับกระบวนการหรือ task_struct
ของเธรดที่เป็นปัญหา ดังนั้นจึงไม่มีการรับประกันว่าจะมีการส่งสัญญาณอย่างทันท่วงที แม้ว่าสัญญาณจะไม่ถูกบล็อกก็ตาม
หรือต่อให้ มี การส่งมอบสัญญาณอย่างทันท่วงที ก็ไม่มีทางที่จะสื่อสารกลับไปยังผู้ออกสัญญาณได้ว่าสถานะของคำขอให้ดำเนินการของผู้ออกสัญญาณเป็นอย่างไร ด้วยเหตุนี้ สัญญาณจึงไม่ควรส่งการดำเนินการใดก็ตามที่มีความหมาย เนื่องจากสัญญาณทำได้เพียงส่งแล้วจบกันโดยไม่มีกลไกอย่างจริงๆ จังๆ ในการรายงานความสำเร็จหรือความล้มเหลวของการส่งและการดำเนินการที่ตามมา อย่างที่เราได้เห็นในตัวอย่างข้างต้น แม้แต่สัญญาณที่ดูเหมือนไม่มีพิษภัยก็อาจเป็นอันตรายได้เมื่อไม่ได้กำหนดค่าในพื้นที่ผู้ใช้
ใครก็ตามที่ใช้ Linux มานานพอต้องเคยพบกรณีที่ตัวเองต้องการหยุดบางกระบวนการโดยไม่ลังเล แต่กลับพบว่ากระบวนการนั้นไม่ตอบสนองแม้แต่กับสัญญาณที่ควรจะได้ผลชะงัดอย่าง SIGKILL
ทั้งนี้ ปัญหาคือการชวนให้เข้าใจผิด จุดประสงค์ของคำสั่ง kill(1) นั้นไม่ใช่เพื่อหยุดกระบวนการ แต่มีไว้เพียงเพื่อจัดคิวคำขอไปยัง Kernel (โดยไม่มีข้อบ่งชี้ว่าจะได้รับบริการเมื่อใด) ว่ามีคนขอให้มีการดำเนินการบางอย่างเท่านั้น
หน้าที่ของการเรียกใช้ระบบด้วยคำสั่ง kill
คือการทำเครื่องหมายสัญญาณว่ารอดำเนินการในเมตาดาต้างานของ Kernel ซึ่งทำได้สำเร็จแม้ว่างาน SIGKILL จะไม่หยุดก็ตาม โดยเฉพาะในกรณีของ SIGKILL
นั้น Kernel รับประกันว่าจะไม่มีการดำเนินการตามขั้นตอนในโหมดผู้ใช้อีกต่อไป แต่เราอาจยังต้องดำเนินการตามขั้นตอนในโหมด Kernel เพื่อให้การดำเนินการที่ถ้าหากปล่อยไว้อาจส่งผลให้ข้อมูลเสียหายนั้นเสร็จสิ้น หรือเพื่อปล่อยทรัพยากร ด้วยเหตุนี้ เราจึงยังคงประสบความสำเร็จแม้ว่าสถานะจะเป็น D (ไม่สามารถขัดจังหวะการพักเครื่องได้) ก็ตาม ซึ่งการหยุดการทำงานของตัวเองจะไม่ล้มเหลวเว้นแต่คุณจะให้สัญญาณที่ไม่ถูกต้อง คุณไม่ได้รับอนุญาตให้ส่งสัญญาณนั้น หรือไม่มี PID ที่คุณร้องขอให้ส่งสัญญาณไปให้ ซึ่งส่งผลให้การเผยแพร่สถานะไม่ปลาย (non-terminal state) ไปยังแอพพลิเคชั่นอย่างมีประสิทธิภาพนั้นไม่มีประโยชน์
วิธีหลีกเลี่ยงปัญหาการจัดการสัญญาณคืออย่าจัดการกับสัญญาณเหล่านั้นเลยจะเป็นการดีที่สุด อย่างไรก็ตาม สำหรับแอพพลิเคชั่นที่จัดการการประมวลผลสถานะซึ่งต้องดำเนินบางอย่างเกี่ยวกับกรณีต่างๆ เช่น SIGTERM
ควรใช้ API ระดับสูง เช่น folly::AsyncSignalHandler
ซึ่งได้มีการทำให้คุณสมบัติจำนวนหนึ่งใช้งานได้ง่ายอยู่แล้ว
sigprocmask
ซึ่งจะเป็นการลดจำนวนโค้ดที่ต้องผ่านการตรวจสอบเป็นประจำเพื่อความถูกต้องของสัญญาณ ทั้งนี้โปรดทราบว่าหากลำดับขั้นตอนของโค้ดหรือกลยุทธ์การสร้างเธรดเปลี่ยนไป คำสั่ง mask อาจไม่ให้ผลลัพธ์ตามที่คุณต้องการsignal(SIGHUP, SIG_IGN); signal(SIGQUIT, SIG_IGN); signal(SIGUSR1, SIG_IGN); signal(SIGUSR2, SIG_IGN);
ลักษณะการทำงานของสัญญาณมีความซับซ้อนอย่างยิ่งในการให้เหตุผลแม้กระทั่งในโปรแกรมที่ได้รับการออกแบบมาอย่างดี และการใช้งานสัญญาณทำให้เกิดความเสี่ยงที่ไม่จำเป็นในแอพพลิเคชั่นที่สามารถใช้ทางเลือกอื่นๆ ได้ โดยทั่วไปแล้ว เราไม่แนะนำให้คุณใช้สัญญาณในการสื่อสารกับส่วนของพื้นที่ผู้ใช้ของโปรแกรม แต่ควรให้โปรแกรมจัดการเหตุการณ์ต่างๆ เองอย่างโปร่งใส (ด้วย inotify เป็นต้น) หรือใช้การสื่อสารในพื้นที่ของผู้ใช้ที่สามารถรายงานข้อผิดพลาดกลับไปยังผู้ออกสัญญาณ และสามารถสร้าง enum รวมถึงพิสูจน์ได้ในขณะรวบรวม เช่น Thrift, gRPC หรือการสื่อสารที่คล้ายกัน
ผมหวังว่าบทความนี้จะแสดงให้คุณเห็นว่า ถึงแม้สัญญาณอาจดูผิวเผินเหมือนเรียบง่าย แต่ในความเป็นจริงแล้วไม่ได้เป็นเช่นนั้นเลย ซึ่งสุนทรียศาสตร์ของความเรียบง่ายที่ส่งเสริมการใช้งานในฐานะ API สำหรับซอฟต์แวร์พื้นที่ผู้ใช้นั้นปฏิเสธการตัดสินใจในการออกแบบโดยนัยที่ไม่เหมาะกับกรณีการใช้งานในระยะใช้งานจริงส่วนใหญ่ในยุคปัจจุบัน
สรุปชัดๆ ก็คือ มีกรณีการใช้งานที่ถูกต้องสำหรับสัญญาณอยู่ ซึ่งสัญญาณนั้นใช้ได้ดีกับการสื่อสารขั้นพื้นฐานกับ Kernel เกี่ยวกับสถานะกระบวนการที่ต้องการเมื่อไม่มีองค์ประกอบของพื้นที่ผู้ใช้ เช่น ในกรณีที่กระบวนการควรหยุดทำงาน อย่างไรก็ตาม การเขียนโค้ดที่สัญญาณถูกต้องในครั้งแรกเมื่อคาดว่าสัญญาณจะติดอยู่ในพื้นที่ผู้ใช้นั้นเป็นเรื่องยาก
สัญญาณอาจดูน่าสนใจเนื่องจากมีการสร้างมาตรฐาน มีความพร้อมใช้งานที่กว้างขวาง และแทบไม่ต้องพึ่งพาเครื่องมืออื่นๆ แต่สัญญาณก็มีข้อผิดพลาดจำนวนมากที่มีแต่จะเพิ่มความกังวลเมื่อโปรเจ็กต์ของคุณเติบโตขึ้น หวังว่าบทความนี้จะทำให้คุณรู้วิธีบรรเทาผลกระทบและให้กลยุทธ์ทางเลือกที่จะช่วยให้คุณยังคงบรรลุเป้าหมายได้ แต่ด้วยวิธีที่ปลอดภัยกว่า ซับซ้อนน้อยกว่า และใช้งานง่ายกว่า
เรียนรู้เพิ่มเติมเกี่ยวกับโอเพนซอร์สของ Meta ได้ที่เว็บไซต์โอเพนซอร์สของเรา, สมัครติดตามช่อง YouTube ของเรา, หรือติดตามเราที่ Twitter, Facebook และ LinkedIn