2 poin oleh GN⁺ 2024-11-30 | 1 komentar | Bagikan ke WhatsApp
  • Pipeline yang menghubungkan output yang masuk perlahan melalui beberapa perintah, seperti tail -f /some/log/file | grep thing1 | grep thing2, sebenarnya tidak macet; ia bisa tampak kosong karena perintah di tengah menumpuk output di buffer
  • grep dan banyak program memeriksa apakah stdout adalah terminal dengan isatty; jika terminal, mereka memakai line buffering, sedangkan jika pipe atau file, mereka memakai block buffering sekitar 8KB
  • tail, cat, dan tee adalah contoh yang tidak melakukan buffering output, tetapi opsi untuk mengurangi buffering berbeda-beda di tiap perintah, seperti grep --line-buffered, sed -u, tcpdump -l, jq -u, tr -u
  • Jika pipeline diputus dengan Ctrl-C, output yang ada di buffer program seperti tcpdump bisa hilang; jika dihentikan dengan kill -TERM $PID, buffer bisa di-flush sehingga output terlihat
  • Solusi praktis mencakup mengganti dengan perintah yang cepat selesai, grep --line-buffered, satu awk atau grep yang lebih kompleks, stdbuf, unbuffer, tetapi masing-masing perlu diperiksa kondisi kerja dan efek sampingnya

Mengapa pipe tampak seperti macet

  • Ketika baris ditambahkan perlahan ke file log, pipeline berikut bisa tidak menampilkan output meskipun ada hasil yang cocok
    • tail -f /some/log/file | grep thing1 | grep thing2
  • Penyebabnya bukan pipe itu sendiri, melainkan grep thing1 di tengah tidak langsung menulis hasilnya dan justru menyimpannya di buffer
  • Jika program langsung menulis setiap kali, jumlah system call meningkat, jadi demi performa ia mengumpulkan sejumlah data terlebih dahulu sebelum menulis ke pipe atau file
  • Dalam contoh ini, grep thing1 bisa menunggu hingga output sekitar 8KB terkumpul, dan pada log yang lambat kondisi itu bisa saja praktis tidak pernah tercapai

Cara output berbeda antara terminal dan pipe

  • tail -f file | grep thing berjalan baik, tetapi jika menambahkan grep kedua di belakangnya, output bisa tampak berhenti
  • grep dan banyak program memeriksa apakah stdout adalah terminal dengan fungsi isatty
    • Jika stdout adalah terminal, mereka memakai line buffering dan langsung mengeluarkan output per baris
    • Jika stdout adalah pipe atau file, mereka memakai block buffering dan baru mengeluarkan output saat data sudah terkumpul di atas ukuran tertentu
  • Karena itu, jika grep menulis langsung ke terminal, baris langsung terlihat, tetapi jika menulis ke pipe yang diteruskan ke perintah berikutnya, output bisa tidak terlihat
  • Ukuran buffer berbeda-beda di tiap program
    • Pada grep, buffering ditangani oleh libc, dan ukuran libc didefinisikan oleh variabel BUFSIZ
    • Lokasi definisi glibc ada di stdio.h
  • Tidak memakai buffer output 8KB saat menulis ke terminal bukanlah hukum fisika; program bisa saja mengimplementasikannya jika mau, tetapi itu akan terasa sebagai perilaku yang sangat aneh

Perilaku buffering berbeda di tiap perintah

  • Alasan buffering output merepotkan adalah pengguna harus mengingat perintah mana yang melakukan buffering saat output-nya berupa pipe
  • Contoh perintah yang tidak melakukan buffering output adalah sebagai berikut
    • tail
    • cat
    • tee
  • Perintah umum yang melakukan buffering output saat menulis ke pipe, beserta cara menguranginya, adalah sebagai berikut
    • grep: --line-buffered
    • sed: -u
    • awk: fungsi fflush()
    • tcpdump: -l
    • jq: -u
    • tr: -u
    • cut: tidak bisa menonaktifkan buffering
  • Untuk perintah seperti sort yang baru bisa bekerja setelah menerima seluruh input, apakah buffering terjadi atau tidak secara praktis tidak terlalu penting
  • Penulis mencoba menguji Mac OS dan versi GNU, tetapi karena ada banyak variasi, beberapa kesalahan mungkin saja ada

Output default bahasa pemrograman juga melakukan buffering

  • Output print default di beberapa bahasa pemrograman juga melakukan buffering saat menulis ke pipe
  • Cara menonaktifkannya per bahasa adalah sebagai berikut
    • C: setvbuf
    • Python: python -u, PYTHONUNBUFFERED=1, sys.stdout.reconfigure(line_buffering=False), print(x, flush=True)
    • Ruby: STDOUT.sync = true
    • Perl: $| = 1
  • Perilaku default seperti ini tampaknya dirancang agar fungsi output default cepat untuk pemrosesan batch
  • Ada atau tidaknya buffering bisa berbeda tergantung cara output dilakukan
    • Di C++, cout << "hello\n" melakukan buffering saat menulis ke pipe
    • cout << "hello" << endl akan melakukan flush output

Perbedaan yang muncul pada Ctrl-C dan redirect file

  • Jika output tcpdump dihubungkan ke grep seperti berikut dan -l terlewat, output bisa tertahan di buffer
    • sudo tcpdump -ni any port 53 | grep example.com
  • Idealnya, saat menekan Ctrl-C, kita bisa berharap tcpdump melakukan flush buffer dan grep mencari sehingga output yang terlewat terlihat
  • Pada kenyataannya, saat program-program berhenti, output yang ada di buffer tcpdump hilang
  • Saat diperiksa dengan strace, grep menerima SIGINT lebih dulu daripada tcpdump, sehingga meskipun tcpdump mencoba melakukan flush, grep mungkin sudah mati
  • Sebagai jalan pintas, jika mencari PID tcpdump lalu menjalankan kill -TERM $PID, tcpdump bisa melakukan flush buffer sehingga output terlihat
  • Redirect file juga melakukan buffering
    • sudo tcpdump -ni any port 53 > output.txt
  • Namun berbeda dari masalah Ctrl-C yang bisa membuang seluruh isi buffer, pada redirect file, berdasarkan pengalaman, isi buffer sering ditulis ke file sebelum program berhenti
  • Belum jelas apakah perilaku ini selalu bisa diandalkan

Lima cara menghindari buffering

  • Mengganti dengan program yang cepat selesai

    • Hindari situasi menulis perlahan ke pipe, dan ubah menjadi perintah yang cepat selesai jika memungkinkan
    • Contohnya sebagai berikut
      • cat /some/log/file | grep thing1 | grep thing2 | tail
    • Ini tidak sama persis dengan perintah tail -f semula, tetapi bisa menghindari masalah buffering yang rumit
  • Menggunakan opsi line buffering pada grep

    • grep memiliki flag untuk menghindari buffering
    • Contohnya sebagai berikut
      • tail -f /some/log/file | grep --line-buffered thing1 | grep thing2
  • Menggabungkan dengan awk atau grep yang lebih kompleks

    • Situasi yang memakai beberapa grep bisa diganti dengan satu awk
      • tail -f /some/log/file | awk '/thing1/ && /thing2/'
    • Atau bisa ditulis dengan grep memakai regex yang lebih kompleks
      • tail -f /some/log/file | grep -E 'thing1.*thing2'
    • Karena awk juga melakukan buffering, agar cara ini bekerja, awk harus menjadi perintah terakhir dalam pipeline
  • Menggunakan stdbuf

    • stdbuf memakai LD_PRELOAD untuk mematikan buffering libc
    • Contoh mematikan buffering output adalah sebagai berikut
      • tail -f /some/log/file | stdbuf -o0 grep thing1 | grep thing2
    • Seperti solusi berbasis LD_PRELOAD, keandalannya terbatas
      • Tidak bekerja pada binary statis
      • Bisa tidak bekerja jika program tidak memakai buffering libc
      • Tidak selalu bekerja di Mac OS
    • Penjelasan terkait ada di How stdbuf works oleh Harry Marr
  • Menggunakan unbuffer

    • unbuffer program memaksa output program seolah-olah berupa TTY, sehingga buffering berkurang seperti pada TTY biasa dan fitur seperti output berwarna digunakan
    • Contohnya sebagai berikut
      • tail -f /some/log/file | unbuffer grep thing1 | grep thing2
    • Berbeda dari stdbuf, ini selalu bekerja, tetapi bisa memiliki efek samping yang tidak diinginkan
      • Misalnya, grep thing1 bisa menambahkan warna pada hasil yang cocok
    • unbuffer ada di dalam paket expect

Situasi yang paling sering memunculkan masalah dan ide variabel lingkungan

  • Masalah seperti ini terutama muncul pada program yang mengalirkan data ke pipe secara perlahan
  • Contohnya sebagai berikut
    • tcpdump
    • tail -f
    • Pemantauan log seperti kubectl logs
    • Output dari komputasi yang lambat
  • Akan berguna jika ada variabel lingkungan standar untuk mematikan buffering, seperti PYTHONUNBUFFERED di Python
  • Ide ini berasal dari tulisan blog Mark Dominus tahun 2018 dan tulisan lanjutannya
  • Sebagai contoh nama, mungkin bisa memakai NO_BUFFER seperti NO_COLOR
  • Desainnya sulit
  • Penulis juga penasaran apakah ada program yang otomatis melakukan flush buffer output secara berkala, misalnya setiap 1 detik, tetapi tidak terpikir ada program seperti itu dan bisa saja ada kekurangannya

Ruang lingkup yang tidak dibahas

  • Perbedaan antara line buffering dan output yang sepenuhnya tanpa buffer dikecualikan
  • Perbedaan antara buffering stderr dan buffering stdout dikecualikan
  • Pembahasan ini hanya mencakup buffering yang terjadi di dalam program
  • Driver TTY milik sistem operasi juga kadang melakukan sedikit buffering
  • Alasan lain untuk melakukan flush output selain saat menulis ke pipe dikecualikan

1 komentar

 
GN⁺ 2024-11-30
Komentar Hacker News
  • Pendekatan yang dibuffer hampir selalu seharusnya memakai pola “ambang batas atau timeout”: flush saat mencapai ambang batas byte, atau setelah waktu tertentu berlalu jika ada setidaknya 1 byte
    Ini adalah pola yang umum di antarmuka hardware untuk menyelesaikan masalah serupa
    Dalam kasus ini, library yang melakukan buffering di user space harus menyetel timer yang sesuai saat pertama kali memasukkan data ke buffer. Nilai timeout bisa diterima sebagai argumen, ditetapkan sekitar 1–100 ms yang terasa singkat bagi manusia, dibuat sebanding dengan {bandwidth / ambang batas}, atau ditetapkan agar overhead system call tidak melebihi 0,1% dari total waktu
    Pola ini berlaku bukan hanya untuk write, tetapi juga untuk read. Jika melakukan read secara batch atau read yang digabungkan, pendekatan serupa diperlukan, tetapi channel data harus punya cara untuk menanyakan atau menerima notifikasi “data yang menunggu” secara efisien, sehingga lebih bergantung pada desain channel. Di hardware, pola seperti interrupt coalescing umum digunakan

    • Saya rasa arah ini benar, tetapi jika libc menyetel timer otomatis, perilaku yang diharapkan akan berubah dan menimbulkan banyak masalah rumit
      Error I/O bisa terjadi bukan hanya saat write, tetapi kapan saja, dan berbagai system call bisa terinterupsi oleh timer, bukan hanya di tempat program memasang timer sendiri atau tempat sinyal tiba
      Jika aplikasi dan libc sama-sama menyetel timer, kemungkinan besar akan membingungkan. API timer kernel modern tampaknya lebih baik daripada yang saya ingat dulu, jadi ini mungkin kurang relevan, tetapi jika aplikasi memblokir sinyal sebentar di bagian penting, timer I/O juga akan terpengaruh
      Karena waktu dan cara penanganan sinyal, akses ke struktur I/O harus dilakukan dengan lebih hati-hati
    • Menangani timeout seperti itu secara transparan tampaknya sulit di bawah batasan POSIX dan ISO C. Sepertinya diperlukan kerja sama tertentu dari lapisan aplikasi
    • Alarm Linux yang umum berbasis sinyal, sehingga sangat sulit dikelola, dan untuk menjadwalkan ulang harus masuk ke kernel, yang bisa berdampak pada performa
      Dengan io_uring dan timer user space, skalanya jauh lebih baik, tetapi untuk mendukung banyak write kecil yang cepat, tetap diperlukan trik. Misalnya, setelah melewati sekitar 1 juta kali per detik, biaya manajemen timer mulai terlihat, dan untuk mencapai 100 juta write per detik diperlukan teknik yang cukup tidak biasa
    • Sulit untuk setuju. Buffering di sini melakukan apa yang memang seharusnya dilakukan
      Sumber masalahnya adalah tercampurnya sesuatu yang seharusnya interaktif dengan kontrak yang tidak mengasumsikan interaksi. Contohnya adalah saat output pelacakan tail dikirim lewat pipe
      Saya rasa tidak ada masalah nyata yang perlu diselesaikan. Jika memakai analogi hardware, ini seperti tangki air yang menampung air hujan dan hanya memindahkannya saat penuh. Saya tidak tahu contoh apa yang Anda maksud, tetapi sejauh yang saya tahu, flush berbasis waktu tidak umum di hardware
      Perbaikan yang diusulkan membuat kontraknya jauh lebih rumit
    • Menurut saya footgun yang dapat diprediksi lebih baik. Idenya bagus, tetapi harus berupa flag terpisah, dan kalau begitu orang harus tahu bahwa flag itu ada
      Masalahnya bukan pada semantiknya sendiri, melainkan pada ketidaktahuan terhadap semantik tersebut
  • Meski sudah menangani sistem NIX lebih dari 20 tahun, saya tahu hal seperti ini terjadi, tetapi setiap kali saya baru teringat setelah lama bingung kenapa output tidak muncul

  • Mengenai bagian “tulisan-tulisan belakangan ini cukup panjang, memangnya ada yang benar-benar ingin membaca tulisan 3000 kata tentang buffering?”, secara pribadi saya ingin membacanya

    • Tergantung tulisannya
      Menurut saya ada juga tulisan panjang yang isinya tambalan tak perlu demi optimasi mesin pencari
    • Untuk bagian seperti ini, ringkasan AI menurut saya cukup membantu. Minta AI merangkum tulisannya lalu tinjau hasilnya
      Bisa juga dengan menyediakan bagian TLDR dan NTLDR, yaitu “panjang, tetapi sudah dibaca”
  • Saya berharap setiap kali CPU seluruh sistem berada dalam keadaan idle, semua buffer di-flush
    Buffering pada dasarnya adalah teknik untuk menghemat CPU. Jika CPU tak terbatas, semua buffer akan berukuran 1 byte. Buffer mengumpulkan data untuk diproses sekaligus demi efisiensi
    Namun saat CPU menjadi idle, tidak boleh ada “pekerjaan untuk nanti” yang tersisa. Begitu scheduler kernel masuk keadaan idle, ia seharusnya mengirim sinyal ke semua proses untuk mem-flush buffer

    • Ide yang menarik. Namun mengirim sinyal ke semua proses terdengar sangat mahal
      Itu berarti setelah semua pekerjaan itu dilakukan, proses masih harus melakukan system call lagi untuk flush buffer. Mungkin bisa ditambahkan mekanisme agar kernel mengetahui buffer user space dan mengambil langsung dari sana saat idle
      Saya bertanya-tanya apakah ini kurang lebih seperti io_uring https://man7.org/linux/man-pages/man3/io_uring_register_buff...
    • Ide yang keren, tetapi sepertinya lebih baik tidak melakukan semuanya sekaligus. Dan pada sistem berdaya rendah, membangunkan proses yang tidur secara spekulatif juga bisa menurunkan efisiensi
  • Tulisan ini mencampuradukkan dua hal berbeda: tanpa buffer dan line buffering
    Tanpa buffer membuat performa buruk tanpa perlu, dan jika beberapa sumber menulis ke pipe yang sama, bisa menghasilkan output yang keliru. Baris yang cukup panjang memang tetap akan tercampur, tetapi sebagian besar baris output di dunia nyata, bahkan termasuk karakter format/kontrol dan karakter supplementary planes, lebih pendek dari 4096 byte
    Line buffering adalah default pada terminal, dan biasanya juga perilaku yang diinginkan pada pipe. Cukup jalankan tiap perintah di bawah stdbuf -oL -eL. Program langka yang ingin memperbarui isi di dalam satu baris memang sudah harus melakukan flush manual, jadi di sini pun akan bekerja dengan benar
    Hal yang sebenarnya dilakukan stdbuf bisa dilihat seperti ini:
    env -i \command -v stdbuf` -oL -eL `command -v env``

  • Saya pernah menulis tentang masalah ini sebelumnya: https://world-playground-deceit.net/blog/2024/09/bourne_shel...
    Untuk perintah yang tidak melakukan buffering, itu bergantung pada implementasi, atau dalam kasus cat mungkin saja salah. Lihat https://pubs.opengroup.org/onlinepubs/9799919799/utilities/c... dan -u. Sangat menyakitkan bahwa POSIX tidak memasukkan cara resmi untuk mengelola hal ini.
    Yang tidak disebutkan adalah buffering input, dan ini menghasilkan hasil aneh seperti ini:
    $ seq 5 | { v1=$(head -1); v2=$(head -1); printf '%s=%s\n' v1 "$v1" v2 "$v2"; }
    v1=1
    v2=
    Dalam kasus ini solusinya adalah memakai stdbuf -i0 head -1.

    • Saya tidak melihat bagaimana proses yang membaca dari sesuatu seperti pipe atau socketpair bisa memaksakan batasan semacam itu pada proses yang menulis. Kecuali kalau memakai hack berat seperti ptrace().
      Mungkin saja ukuran buffer pipe bisa disetel, tetapi saya tidak tahu ada konvensi bahwa I/O C standar harus mengikuti itu.
      Bagaimanapun, dalam kasus ini stdbuf tampaknya tidak membantu:
      $ ./a | stdbuf -i0 -- cat
      #include
      #include
      int main(void) {
      for (;;) {
      printf("n");
      usleep(100000);
      }
      }
  • Ada alasan bagus mengapa buffer itu ada. Mencetak output ke layar relatif sangat lambat dibandingkan menulis ke buffer.
    Mengeluarkan karakter satu per satu sangat tidak efisien.
    Ini masalah lama, dan sering ditemui saat menangani UART. Ada beberapa kemungkinan solusi: pendekatan berbasis baris yang menandai akhir output dengan karakter khusus seperti baris baru, pendekatan berbasis panjang yang menunggu sampai mencapai panjang seperti 8KB, atau pendekatan berbasis waktu yang mengeluarkan output setiap X milidetik.
    Masing-masing punya kelebihan dan kekurangan, dan mana yang optimal bergantung pada aplikasinya. Menurut saya bagian di tulisan yang mengatakan program-program tertentu tidak memakai buffering itu salah. Program-program itu hanya tidak memakai pendekatan berbasis panjang yang eksplisit.

    • Ini bekerja paling baik ketika ada cara yang mengetahui batasannya satu atau dua lapisan di atas antarmuka.
      Pendekatan berbasis baris adalah salah satu cara seperti itu, tetapi perlu kesepakatan tentang karakter apa yang dipakai. Biasanya baris baru.
    • Ini bukan hanya soal biaya backend yang menangani penulisan sebenarnya. Melakukan system call sebanyak itu ke /dev/null saja bisa sangat membunuh performa.
  • Saya sudah memakai Unix selama lebih dari 35 tahun, tetapi tidak pernah benar-benar memahami bagaimana ini bekerja.
    Senang ada penjelasan menyeluruh tentang perilaku buffering di berbagai sistem dan komponen, dan saya jelas belajar sesuatu.

  • Mengenai bagian “jika menekan Ctrl-C di pipe, isi buffer akan hilang”, saya kira sebagian besar program akan mem-flush buffer saat SIGINT.
    Namun agar shell berperilaku seperti itu, SIGINT harus diteruskan hanya ke program pertama dalam pipeline, dan kemungkinan perilaku sebenarnya bukan begitu.

    • Seingat saya proses terakhir menerima sigint dan sisanya menerima sigpipe