Kembali ke Beranda untuk Developer

Signals in prod: dangers and pitfalls

27 September 2022OlehChris Down

Dalam postingan blog ini, Chris Down, seorang Insinyur Kernel di Meta, membahas kesalahan dalam menggunakan sinyal Linux di lingkungan produksi Linux dan mengapa developer harus menghindari penggunaan sinyal jika memungkinkan.

Apa itu Sinyal Linux?

Sinyal adalah peristiwa yang dihasilkan sistem Linux sebagai tanggapan terhadap beberapa kondisi. Sinyal dapat dikirim oleh kernel ke suatu proses, oleh proses ke proses lain, atau proses itu sendiri. Setelah menerima sinyal, suatu proses dapat mengambil tindakan.

Sinyal adalah bagian inti dari lingkungan operasi mirip Unix dan telah ada sejak awal waktu. Sinyal tersebut adalah pipa untuk banyak komponen inti dari sistem operasi—timbunan inti (core dumping), pengelolaan siklus proses, dsb.—dan secara umum, sinyal telah bertahan cukup baik dalam penggunaan kita selama lima puluh tahun atau lebih. Dengan demikian, ketika seseorang berkata bahwa menggunakan sinyal untuk komunikasi antarproses (IPC) memiliki potensi bahaya, banyak orang mungkin berpikir ini adalah ocehan orang yang putus asa dan buang-buang waktu menemukan sesuatu yang sudah ditemukan orang lain. Namun, artikel ini akan menunjukkan kasus ketika sinyal adalah penyebab masalah produksi. Artikel ini juga menawarkan beberapa mitigasi dan alternatif potensial.

Sinyal mungkin tampak menarik karena standardisasinya, ketersediaannya yang luas, dan fakta bahwa sinyal tersebut tidak memerlukan dependensi tambahan apa pun di luar apa yang disediakan oleh sistem operasi. Namun, sinyal bisa sulit digunakan dengan aman. Sinyal memiliki banyak asumsi sehingga orang harus berhati-hati dalam memvalidasi agar cocok dengan persyaratan mereka. Jika tidak, orang harus berhati-hati untuk mengonfigurasikan dengan benar. Pada kenyataannya, banyak aplikasi, bahkan aplikasi yang dikenal luas, tidak melakukan validasi ataupun konfigurasi dengan benar. Akibatnya, mungkin aplikasi tersebut akan mengalami insiden yang sulit di-debug di masa mendatang.

Mari kita lihat insiden baru-baru ini yang terjadi di lingkungan produksi Meta, yang menekankan kesalahan penggunaan sinyal. Kita akan membahas secara singkat sejarah beberapa sinyal dan bagaimana sinyal tersebut membawa kita ke posisi sekarang, dan kemudian kita akan membandingkannya dengan kebutuhan dan masalah saat ini yang kita lihat dalam produksi.

Insiden

Pertama-tama, mari kita mundur sedikit. Tim LogDevice membersihkan basis kode mereka, menghapus kode dan fitur yang tidak digunakan. Salah satu fitur yang tidak digunakan lagi adalah jenis catatan yang mendokumentasikan operasi tertentu yang dilakukan oleh layanan. Fitur ini akhirnya mubazir, tidak memiliki konsumen, dan karena itu dihapus. Anda dapat melihat perubahannya di sini di GitHub. Sejauh ini, bagus.

Beberapa saat berikutnya, setelah perubahan berlanjut tanpa harus banyak diceritakan, produksi terus berjalan dengan stabil dan melayani traffic seperti biasa. Beberapa minggu kemudian, diterimalah laporan bahwa node layanan hilang dengan tingkat mengejutkan. Itu ada hubungannya dengan peluncuran rilis baru, tetapi apa yang sebenarnya salah masih belum jelas. Apa yang berbeda sekarang yang menyebabkan hal-hal menjadi kacau?

Tim bersangkutan mempersempit masalah menjadi perubahan kode yang kami sebutkan terdahulu, membuat catatan-catatan ini tidak berlaku lagi. Lantas, apa alasannya? Apa yang salah dengan kode itu? Jika Anda belum mengetahui jawabannya, kami mengundang Anda untuk melihat perbedaan itu dan mencoba mencari tahu apa yang salah karena itu tidak langsung terlihat, dan itu adalah kesalahan yang bisa dilakukan siapa pun.

logrotate, Masuk ke Ring

logrotate kurang lebih merupakan fitur standar untuk rotasi catatan saat menggunakan Linux. logrotate sudah ada selama hampir tiga puluh tahun sekarang, dan konsepnya sederhana: mengelola siklus catatan dengan merotasi dan memvakumnya.

logrotate tidak mengirim sinyal apa pun dengan sendirinya, jadi Anda tidak akan menemukan banyak, jika ada, tentang logrotate di halaman utama logrotate atau dokumentasinya. Namun, logrotate dapat mengambil perintah arbitrer untuk dieksekusi sebelum atau sesudah rotasinya. Seperti contoh dasar dari konfigurasi logrotate default di CentOS, Anda dapat melihat konfigurasi ini:

/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
}

Agak rapuh, tetapi kami akan memaafkan itu dan menganggap ini berfungsi sebagaimana mestinya. Konfigurasi ini berkata bahwa setelah logrotate merotasi file apa pun yang tercantum dalam daftar, logrotate seharusnya mengirim SIGHUP ke PID yang ada di dalam /var/run/syslogd.pid, yang seharusnya menjalankan instance syslogd.

Ini semua baik untuk sesuatu dengan API publik yang stabil seperti syslog, tetapi bagaimana dengan sesuatu yang internal di mana penerapan SIGHUP adalah detail penerapan internal yang dapat berubah sewaktu-waktu?

Riwayat Hangup (Macet)

Salah satu masalah di sini adalah—kecuali untuk sinyal yang tidak dapat ditangkap di ruang pengguna sehingga hanya memiliki satu arti, seperti SIGKILL dan SIGSTOP—arti semantik dari sinyal itu terserah developer aplikasi dan pengguna untuk menafsirkan dan memprogram. Dalam beberapa kasus, perbedaannya sebagian besar bersifat akademis, seperti SIGTERM, yang cukup banyak dipahami secara universal berarti "penghentian dengan baik sesegera mungkin". Namun dalam hal SIGHUP, artinya secara signifikan kurang jelas.

SIGHUP diciptakan untuk lini serial dan pada awalnya digunakan untuk menunjukkan bahwa ujung lain dari koneksi telah terputus. Saat ini, kita masih meneruskan lini ini tentu saja, jadi SIGHUP masih dikirim untuk padanan modernnya: di mana terminal semu (pseudo-terminal) atau terminal virtual ditutup (makanya ada fitur seperti nohup yang menutupinya).

Pada hari-hari awal Unix, ada kebutuhan untuk menerapkan pemuatan ulang daemon. Ini biasanya terdiri dari setidaknya pembukaan kembali file konfigurasi/catatan tanpa memulai ulang, dan sinyal tampak seperti cara bebas dependensi untuk mencapainya. Tentu saja, tidak ada sinyal untuk hal seperti itu, tetapi karena daemon ini tidak memiliki terminal pengontrol, seharusnya tidak ada alasan untuk menerima SIGHUP, jadi sepertinya sinyal yang mudah untuk membonceng (piggyback) tanpa efek samping yang jelas.

Namun ada halangan kecil dengan rencana ini. Status default untuk sinyal tidak "diabaikan", tetapi spesifik sinyal. Contoh: program tidak harus mengonfigurasi SIGTERM secara manual untuk menghentikan aplikasinya. Selama program tidak mengatur sinyal lain, kernel hanya merekam program secara gratis, tanpa kode apa pun yang diperlukan di ruang pengguna. Mudah!

Yang tidak mudah adalah bahwaSIGHUP juga memiliki perilaku menghentikan program segera. Ini berfungsi baik untuk kasus hangup asli, ketika aplikasi ini mungkin tidak diperlukan lagi, tetapi tidak begitu bagus untuk arti baru ini.

Tentu saja ini tidak apa-apa jika kita menghapus semua tempat yang berpotensi mengirim SIGHUP ke program. Masalahnya adalah bahwa dalam database besar dan mapan, hal itu sulit. SIGHUP tidak seperti panggilan IPC yang dikontrol ketat sehingga Anda dapat dengan mudah memahami basis kodenya. Sinyal dapat datang dari mana saja, kapan saja, dan ada beberapa pemeriksaan pada operasinya (selain yang paling dasar "apakah Anda pengguna ini atau memiliki CAP_KILL"). Intinya adalah sulit untuk menentukan dari mana sinyal bisa datang, tetapi dengan IPC yang lebih eksplisit, kita akan tahu bahwa sinyal ini tidak berarti apa-apa bagi kita dan harus diabaikan.

Dari Hangup ke Bahaya

Sekarang, saya kira Anda mungkin sudah mulai menebak apa yang terjadi. Rilis LogDevice dimulai pada suatu sore berisi perubahan kode yang disebutkan di atas. Pada awalnya, tidak ada yang salah, tetapi pada tengah malam keesokan harinya, semuanya secara misterius mulai kacau. Alasannya adalah bait berikut dalam konfigurasi logrotate mesin, yang mengirimkan SIGHUP yang kini tidak tertangani (dan karena itu, fatal) ke daemon logdevice:

/var/log/logdevice/audit.log {
  daily
  # [...]
  postrotate
    pkill -HUP logdeviced
  endscript
}

Satu bait pendek hilang dari konfigurasi logrotate itu sangat mudah dan umum terjadi ketika menghapus fitur besar. Sayangnya, sulit juga untuk memastikan bahwa setiap sisa terakhir eksistensinya telah dihapus sekaligus. Bahkan dalam kasus yang lebih mudah untuk divalidasi daripada ini, kesalahan masih ada sisa saat melakukan pembersihan kode itu sering terjadi. Namun, biasanya, itu tanpa konsekuensi yang merusak, yaitu: sisa-sisanya mati atau kode tanpa operasi.

Secara konseptual, insiden itu sendiri dan resolusinya sederhana: jangan kirim SIGHUP, dan sebarkan tindakan LogDevice lebih banyak dari waktu ke waktu (yaitu: jangan jalankan ini pada tengah malam secara langsung). Namun, bukan hanya nuansa kejadian yang satu ini yang harus kita fokuskan di sini. Insiden ini, lebih dari segalanya, harus berfungsi sebagai platform untuk mencegah penggunaan sinyal dalam produksi untuk apa pun selain kasus yang paling mendasar dan penting.

Bahaya Sinyal

Kegunaan Sinyal

Pertama, penggunaan sinyal sebagai mekanisme untuk memengaruhi perubahan status proses sistem operasi itu mantap. Ini termasuk sinyal seperti SIGKILL, yang tidak mungkin diinstal penangan sinyal dan melakukan persis seperti yang Anda harapkan, dan perilaku default-kernel dari SIGABRT, SIGTERM, SIGINT, SIGSEGV, dan SIGQUIT dan sejenisnya, yang umumnya dipahami dengan baik oleh pengguna dan pemrogram.

Kesamaan dari semua sinyal ini adalah bahwa setelah Anda menerimanya, semua sinyal ini maju menuju status akhir terminal di dalam kernel itu sendiri. Artinya, tidak ada lagi instruksi ruang pengguna yang akan dieksekusi setelah Anda mendapatkan SIGKILL atau SIGTERM tanpa penangan sinyal ruang pengguna.

Status akhir terminal penting karena biasanya berarti Anda berupaya mengurangi kompleksitas stack dan kode yang saat ini sedang dieksekusi. Status lain yang diinginkan sering berakibat malah makin kompleks dan lebih sulit untuk dinalar karena konkurensi dan alur kode menjadi lebih kacau.

Perilaku Default Berbahaya

Anda mungkin memperhatikan bahwa kami tidak menyebutkan beberapa sinyal lain yang juga berhenti secara default. Berikut daftar semua sinyal standar yang berhenti secara default (tidak termasuk sinyal timbunan inti (core dump) seperti SIGABRT atau SIGSEGV karena mereka semua masuk akal):

  • SIGALRM
  • SIGEMT
  • SIGHUP
  • SIGINT
  • SIGIO
  • SIGKILL
  • SIGLOST
  • SIGPIPE
  • SIGPOLL
  • SIGPROF
  • SIGPWR
  • SIGSTKFLT
  • SIGTERM
  • SIGUSR1
  • SIGUSR2
  • SIGVTALRM

Sepintas, ini mungkin tampak masuk akal, tetapi berikut adalah beberapa pencilan (outlier):

  • SIGHUP: Jika ini digunakan hanya seperti yang dimaksudkan, default untuk menghentikan memang masuk akal. Dengan penggunaan campuran saat ini yang berarti "membuka kembali file", ini berbahaya.
  • SIGPOLL dan SIGPROF: Ini ada di bagian "ini harus ditangani secara internal oleh beberapa fungsi standar daripada oleh program Anda." Namun, meskipun mungkin tidak berbahaya, perilaku default untuk menghentikan tampaknya masih tidak ideal.
  • SIGUSR1 dan SIGUSR2: Ini adalah "sinyal yang ditentukan pengguna" yang seolah-olah dapat Anda gunakan sesuka Anda. Tetapi karena ini adalah terminal secara default, jika Anda menerapkan USR1 untuk beberapa kebutuhan khusus dan kemudian tidak membutuhkannya, Anda tidak dapat menghapus kode dengan aman. Anda harus dengan sadar memikirkan pengabaian sinyal secara eksplisit. Itu benar-benar tidak akan jelas bahkan bagi setiap pemrogram berpengalaman.

Jadi itu hampir sepertiga dari sinyal terminal, yang paling baik dipertanyakan dan, paling buruk, secara aktif berbahaya karena kebutuhan program berubah. Lebih buruk lagi, bahkan sinyal yang seharusnya "ditentukan pengguna" bisa menjadi bencana yang akan terjadi ketika seseorang lupa melakukan SIG_IGN secara eksplisit. Bahkan SIGUSR1 atau SIGPOLL yang tidak berbahaya juga dapat menyebabkan insiden.

Ini bukan hanya masalah familiaritas. Tidak peduli seberapa baik Anda mengetahui cara kerja sinyal, menulis kode sinyal yang benar untuk pertama kalinya sangatlah sulit karena, terlepas dari penampilannya, sinyal jauh lebih kompleks daripada yang terlihat.

Alur kode, Konkurensi, dan Mitos SA_RESTART

Pemrogram umumnya tidak menghabiskan waktu mereka untuk memikirkan cara kerja sinyal. Ini berarti bahwa ketika benar-benar menerapkan penanganan sinyal, pemrogram sering melakukan hal yang salah secara halus.

Saya bahkan tidak berbicara tentang kasus "sepele", seperti keamanan dalam fungsi penanganan sinyal, yang sebagian besar diselesaikan hanya dengan menabrak sig_atomic_t, atau menggunakan hal pagar sinyal atomik dari C++. Tidak, itu sebagian besar mudah dicari dan diingat sebagai kesalahan oleh siapa pun setelah pertama kali mengalami masalah sinyal. Yang jauh lebih sulit adalah penalaran tentang aliran kode bagian nominal dari program yang kompleks ketika menerima sinyal. Melakukannya harus memikirkan sinyal terus-menerus secara eksplisit di setiap bagian dari siklus aplikasi (hei, bagaimana dengan EINTR, apakah SA_RESTART cukup di sini? Alur apa yang harus kita masuki jika ini berhenti sebelum waktunya? Saya sekarang memiliki program bersamaan, apa implikasinya?), atau menyiapkan sigprocmask atau pthread_setmask untuk beberapa bagian dari siklus aplikasi Anda dan berharap agar alur kode tidak pernah berubah (yang tentu saja bukan tebakan yang baik dalam suasana pengembangan yang serbacepat). signalfd atau menjalankan sigwaitinfo di utas khusus dapat membantu agak di sini, tetapi keduanya memiliki kasus edge dan masalah kegunaan yang cukup membuatnya sulit untuk direkomendasikan.

Kami ingin percaya bahwa sebagian besar pemrogram berpengalaman sekarang tahu bahwa bahkan contoh jenaka dari menulis kode aman-utas dengan benar sangatlah sulit. Jika Anda pikir menulis kode aman-utas dengan benar itu sulit, sinyal secara signifikan lebih sulit. Penangan sinyal hanya boleh mengandalkan kode bebas-kunci dengan struktur data atomik, masing-masing, karena alur utama eksekusi ditangguhkan dan kami tidak tahu kunci apa yang dipegangnya, dan karena alur utama eksekusi dapat melakukan operasi non-atomik. Penangan sinyal juga harus sepenuhnya masuk kembali, yaitu: harus dapat berlapis di dalam diri sendiri karena penangan sinyal dapat tumpang tindih jika sinyal dikirim beberapa kali (atau bahkan dengan satu sinyal, dengan SA_NODEFER). Itulah salah satu alasan mengapa Anda tidak dapat menggunakan fungsi seperti printf atau malloc dalam penangan sinyal karena fungsi-fungsi tersebut mengandalkan mutex global untuk sinkronisasi. Jika Anda menahan kunci itu saat sinyal diterima dan kemudian memanggil fungsi yang membutuhkan kunci itu lagi, aplikasi Anda akan menemui jalan buntu. Ini benar-benar sulit untuk dinalar. Itu sebabnya banyak orang hanya menulis sesuatu seperti berikut ini sebagai penanganan sinyal mereka:

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 */
}

Masalahnya adalah, selagi demikian, signalfd, atau upaya lain dalam penanganan sinyal asinkron mungkin terlihat cukup sederhana dan kuat, hal ini mengabaikan fakta bahwa titik interupsi itu sama pentingnya dengan tindakan yang dilakukan setelah menerima sinyal. Contoh: anggaplah kode ruang pengguna Anda sedang melakukan I/O atau mengubah metadata objek yang berasal dari kernel (seperti inode atau FD). Dalam hal ini, Anda mungkin benar-benar berada di stack ruang kernel pada saat interupsi. Contoh: inilah tampilan utas saat mencoba menutup deskriptor file:

# 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

Di sini, __x64_sys_close adalah varian x86_64 dari panggilan sistem close, yang menutup deskriptor file. Pada titik ini dalam eksekusinya, kami sedang menunggu penyimpanan cadangan diperbarui (yaitu wait_on_page_bit). Karena kerja I/O biasanya beberapa kali lipat lebih lambat daripada operasi lainnya, schedule di sini adalah cara untuk secara sukarela memberi isyarat kepada penjadwal CPU kernel bahwa kita akan melakukan operasi latensi tinggi (seperti I/O disk atau jaringan) dan penjadwal harus mempertimbangkan untuk mencari proses lain untuk dijadwalkan daripada proses saat ini. Ini bagus, karena memungkinkan kita untuk memberi sinyal ke kernel bahwa melanjutkan dan memilih proses yang benar-benar akan menggunakan CPU adalah ide yang baik daripada membuang waktu untuk proses yang tidak dapat dilanjutkan sampai selesai menunggu tanggapan dari sesuatu yang mungkin memakan waktu cukup lama.

Bayangkan kita mengirim sinyal ke proses yang sedang kita jalankan. Sinyal yang telah kita kirim memiliki penangan ruang pengguna di utas penerima, jadi kita akan melanjutkan di ruang pengguna. Salah satu dari banyak kemungkinan akhir balapan ini adalah kernel akan mencoba keluar dari schedule, lebih lanjut melepas stack dan akhirnya menampilkan kesalahan ESYSRESTART atau EINTR ke ruang pengguna untuk menunjukkan bahwa kita terinterupsi. Namun seberapa jauh kita berhasil menutupnya? Apa status deskriptor file sekarang?

Sekarang setelah kita kembali ke ruang pengguna, kita akan menjalankan penangan sinyal. Saat penangan sinyal keluar, kami akan menyebarkan kesalahan ke pembungkus close libc ruang pengguna, lalu ke aplikasi—yang secara teori—dapat melakukan sesuatu tentang situasi yang dihadapi. Kami mengatakan "dalam teori" karena sangat sulit untuk mengetahui apa yang harus dilakukan dalam berbagai situasi dengan sinyal, dan banyak layanan dalam produksi tidak menangani kasus edge dengan baik. Itu mungkin tidak apa-apa di beberapa aplikasi di mana integritas data tidak begitu penting. Namun, dalam aplikasi produksi yang memang peduli tentang konsistensi dan integritas data, ini menghadirkan masalah yang signifikan: kernel tidak mengekspos cara granular apa pun untuk memahami seberapa jauh yang didapat, apa yang dicapai dan yang tidak dicapai, dan apa yang sebenarnya harus kita lakukan terhadap situasi tersebut. Lebih buruk lagi, jika close memberikan EINTR, status deskriptor file sekarang tidak ditentukan:

“If close() is interrupted by a signal [...] the state of [the file descriptor] is unspecified.”

Semoga berhasil mencoba dalam menalar cara menanganinya dengan aman dan selamat di aplikasi Anda. Secara umum, menangani EINTR bahkan untuk panggilan sistem yang berperilaku baik itu rumit. Ada banyak masalah halus yang membentuk bagian besar dari alasan mengapa SA_RESTART tidak cukup. Tidak semua panggilan sistem dapat dimulai ulang, dan mengharapkan setiap developer aplikasi Anda bisa memahami dan memitigasi nuansa mendalam dari mendapatkan sinyal untuk setiap panggilan sistem di setiap situs panggilan itu adalah meminta pemadaman. Dari man 7 signal:

“The following interfaces are never restarted after being interrupted by a signal handler, regardless of the use of SA_RESTART; they always fail with the error EINTR [...]”

Demikian juga, menggunakan sigprocmask dan mengharapkan alur kode tetap statis menimbulkan masalah karena developer biasanya tidak menghabiskan waktu untuk memikirkan batas-batas penanganan sinyal atau bagaimana menghasilkan atau mempertahankan kode sinyal yang benar. Hal yang sama berlaku untuk menangani sinyal di utas khusus dengan sigwaitinfo, yang dapat dengan mudah berakhir dengan GDB dan fitur serupa tidak dapat melakukan debug proses. Alur kode yang salah secara halus atau penanganan kesalahan dapat mengakibatkan bug, crash, kerusakan yang sulit di-debug, kebuntuan, dan banyak lagi masalah yang akan membuat Anda langsung disambut hangat oleh fitur pengelolaan insiden pilihan Anda.

Kompleksitas Tinggi dalam Lingkungan Multiutas

Jika Anda memikirkan semua pembahasan tentang konkurensi, reentrancy, dan atomisitas ini cukup buruk, memasukkan multiutas ke dalam campuran tersebut membuat segalanya menjadi lebih rumit. Ini sangat penting ketika mempertimbangkan fakta bahwa banyak aplikasi kompleks menjalankan utas terpisah secara implisit, contoh: sebagai bagian dari jemalloc, GLib, atau sejenisnya. Beberapa dari pustaka ini bahkan menginstal penangan sinyal sendiri, membuka masalah worm lainnya.

Secara keseluruhan, man 7 signal berkata demikian tentang hal tersebut:

“A signal may be generated (and thus pending) for a process as a whole (e.g., when sent using kill(2)) or for a specific thread [...] If more than one of the threads has the signal unblocked, then the kernel chooses an arbitrary thread to which to deliver the signal.”

Lebih ringkasnya, "untuk sebagian besar sinyal, kernel mengirimkan sinyal ke utas mana pun yang tidak memblokir sinyal dengan sigprocmask". SIGSEGV, SIGILL, dan sejenisnya menyerupai jebakan, dan memiliki sinyal yang secara eksplisit diarahkan ke utas yang menyinggung. Namun, terlepas dari apa yang mungkin dipikirkan orang, sebagian besar sinyal tidak dapat secara eksplisit dikirim ke satu utas dalam grup utas, bahkan dengan tgkill atau pthread_kill.

Ini berarti bahwa Anda tidak dapat dengan mudah mengubah keseluruhan karakteristik penanganan sinyal segera setelah Anda memiliki set utas. Jika suatu layanan perlu melakukan pemblokiran sinyal secara berkala dengan sigprocmask di utas utama, Anda perlu berkomunikasi dengan utas lain secara eksternal tentang bagaimana utas lain harus menangani hal tersebut. Jika tidak, sinyal dapat ditelan oleh utas lain, tidak akan pernah terlihat lagi. Tentu saja, Anda dapat memblokir sinyal di utas anak untuk menghindari hal ini, tetapi jika mereka perlu melakukan penanganan sinyal sendiri, bahkan untuk hal-hal primitif seperti waitpid, itu akan membuat segalanya menjadi rumit.

Sama seperti semua hal lain di sini, ini bukan masalah yang secara teknis tidak dapat diatasi. Namun, orang akan lalai dalam mengabaikan fakta bahwa kompleksitas sinkronisasi yang diperlukan agar ini berfungsi dengan benar itu merepotkan, dan meletakkan dasar untuk bug, kebingungan, dan lebih buruk lagi.

Kurangnya Definisi dan Komunikasi Berhasil atau Gagal

Sinyal disebarkan secara asinkron di kernel. Panggilan sistem kill kembali segera setelah sinyal tertunda direkam untuk proses atau task_struct utas yang dimaksud. Jadi, tidak ada jaminan pengiriman tepat waktu, meskipun sinyal tidak diblokir.

Bahkan jika ada itu pengiriman sinyal yang tepat waktu, tidak ada cara untuk berkomunikasi kembali ke penerbit sinyal tentang status permintaan tindakan mereka. Dengan demikian, setiap tindakan yang berarti tidak boleh dikirimkan oleh sinyal, karena mereka hanya menerapkan fire-and-forget (tembak dan lupakan) tanpa mekanisme nyata untuk melaporkan keberhasilan atau kegagalan pengiriman dan tindakan selanjutnya. Seperti yang telah kita lihat di atas, bahkan sinyal yang tampaknya tidak berbahaya pun bisa berbahaya jika tidak dikonfigurasi di ruang pengguna.

Siapa pun yang menggunakan Linux cukup lama pasti akan mengalami kasus di mana mereka ingin mematikan beberapa proses tetapi menemukan bahwa prosesnya tidak responsif bahkan terhadap sinyal yang seharusnya selalu fatal seperti SIGKILL. Masalahnya adalah bahwa secara menyesatkan, tujuan kill(1) bukanlah untuk mematikan proses, tetapi hanya untuk mengantrekan permintaan ke kernel (tanpa indikasi kapan akan dilayani) bahwa seseorang telah meminta beberapa tindakan untuk diambil.

Tugas panggilan sistem kill adalah menandai sinyal sebagai tertunda dalam metadata tugas kernel, yang berhasil bahkan ketika tugas SIGKILL tidak mati. Dalam hal SIGKILL khususnya, kernel menjamin bahwa tidak ada lagi instruksi mode pengguna yang akan dieksekusi, tetapi kami mungkin masih harus mengeksekusi instruksi dalam mode kernel untuk menyelesaikan tindakan yang dapat mengakibatkan kerusakan data atau untuk merilis sumber daya. Untuk alasan ini, kami masih berhasil bahkan jika statusnya D (tidur tidak terputus). KILL sendiri tidak gagal kecuali Anda memberikan sinyal yang tidak valid, Anda tidak memiliki izin untuk mengirim sinyal itu atau PID yang Anda minta untuk mengirim sinyal tidak ada dan dengan demikian tidak berguna untuk menyebarkan status non-terminal secara andal ke aplikasi.

Kesimpulan

  • Sinyal oke untuk status terminal yang ditangani murni di dalam kernel tanpa penangan ruang pengguna. Untuk sinyal bahwa Anda sebenarnya ingin segera mematikan program, biarkan sinyal tersebut ditangani oleh kernel. Ini juga berarti bahwa kernel mungkin dapat keluar lebih awal dari pekerjaannya, membebaskan sumber daya program Anda lebih cepat, sedangkan permintaan ruang pengguna IPC harus menunggu bagian ruang pengguna untuk mulai mengeksekusi lagi.
  • Cara untuk menghindari masalah penanganan sinyal adalah dengan tidak menanganinya sama sekali. Namun, untuk aplikasi yang menangani pemrosesan status yang harus melakukan sesuatu tentang kasus seperti SIGTERM, idealnya gunakan API level tinggi seperti folly::AsyncSignalHandler yang sejumlah warts-nya telah dibuat lebih intuitif.

  • Hindari menyampaikan permintaan aplikasi dengan sinyal. Gunakan notifikasi yang dikelola sendiri (seperti inotify) atau RPC ruang pengguna dengan bagian khusus dari siklus aplikasi untuk menanganinya alih-alih mengandalkan interupsi aplikasi.
  • Jika memungkinkan, batasi lingkup sinyal ke subbagian program atau utas Anda dengan sigprocmask, mengurangi jumlah kode yang perlu diperiksa secara teratur apakah sinyal sudah benar. Ingatlah bahwa jika alur kode atau strategi utas berubah, sigprocmask mungkin tidak memiliki efek yang Anda inginkan.
  • Pada awal daemon, tutupi sinyal terminal yang tidak dipahami secara seragam dan dapat digunakan kembali di beberapa titik dalam program Anda agar tidak kembali ke perilaku default kernel. Saran saya adalah sebagai berikut:
signal(SIGHUP, SIG_IGN);
signal(SIGQUIT, SIG_IGN);
signal(SIGUSR1, SIG_IGN);
signal(SIGUSR2, SIG_IGN);

Perilaku sinyal sangat rumit untuk dinalar, bahkan dalam program yang ditulis dengan baik, dan penggunaannya menghadirkan risiko yang tidak perlu dalam aplikasi yang memiliki alternatif lain. Secara umum, jangan gunakan sinyal untuk berkomunikasi dengan bagian ruang pengguna dari program Anda. Sebagai gantinya, mintalah program secara transparan menangani peristiwa itu sendiri (contoh: dengan inotify), atau gunakan komunikasi ruang pengguna yang dapat melaporkan kembali kesalahan ke penerbit dan dapat dihitung serta ditunjukkan pada waktu kompilasi, seperti Thrift, gRPC, atau sejenisnya.

Saya harap artikel ini telah menunjukkan kepada Anda bahwa sinyal, meskipun mungkin tampak sederhana, pada kenyataannya tidak sederhana. Estetika kesederhanaan yang mempromosikan penggunaannya sebagai API untuk perangkat lunak ruang pengguna memungkiri serangkaian keputusan desain implisit yang tidak sesuai dengan sebagian besar kasus penggunaan produksi di era modern.

Mari kita perjelas: ada kasus penggunaan yang valid untuk sinyal. Sinyal itu oke untuk komunikasi dasar dengan kernel tentang status proses yang diinginkan ketika tidak ada komponen ruang pengguna, contoh: bahwa suatu proses harus dimatikan. Namun, sangatlah sulit untuk langsung berhasil menulis kode yang benar untuk sinyal yang diperkirakan akan terjebak di ruang pengguna.

Sinyal mungkin tampak menarik karena standardisasinya, ketersediaannya yang luas, dan kurangnya dependensi, tetapi sinyal membawa banyak bahaya yang hanya akan meningkatkan kekhawatiran seiring pertumbuhan proyek Anda. Semoga artikel ini telah memberi Anda beberapa mitigasi dan strategi alternatif yang akan memungkinkan Anda untuk tetap mencapai tujuan Anda, tetapi dengan cara yang lebih aman, tidak terlalu rumit, dan lebih intuitif.

Untuk mempelajari selengkapnya tentang Meta Open Source, kunjungi situs sumber terbuka kami, silakan berlangganan saluran YouTube kami, atau ikuti kami di Twitter, dan Facebook, dan LinkedIn.