2 puan yazan GN⁺ 2024-11-30 | 1 yorum | WhatsApp'ta paylaş
  • tail -f /some/log/file | grep thing1 | grep thing2 gibi yavaş gelen çıktıyı birden fazla komutla bağlayan pipeline’lar aslında takılmış değildir; ara komut çıktıyı buffer’da biriktirdiği için boş görünebilir
  • grep ve birçok program, stdout’un terminal olup olmadığını isatty ile kontrol eder; terminalse satır buffering, pipe veya dosyaysa yaklaşık 8KB’lık blok buffering kullanır
  • tail, cat, tee çıktı buffering’i yapmayan örneklerdir; ancak grep --line-buffered, sed -u, tcpdump -l, jq -u, tr -u gibi buffering’i azaltma seçenekleri komuttan komuta değişir
  • Ctrl-C ile pipeline’ı keserseniz tcpdump gibi programların buffer’ındaki çıktı kaybolabilir; kill -TERM $PID ile sonlandırırsanız buffer flush edilir ve çıktı görünebilir
  • Pratik çözümler hızlı biten komutlara geçmek, grep --line-buffered, tek bir awk ya da daha karmaşık grep, stdbuf, unbuffer gibi yöntemlerdir; ancak her birinin çalışma koşulları ve yan etkileri kontrol edilmelidir

Pipe takılmış gibi neden görünür?

  • Bir günlük dosyasına satırlar yavaş yavaş eklendiğinde, aşağıdaki pipeline eşleşen sonuçlar olsa bile çıktı göstermeyebilir
    • tail -f /some/log/file | grep thing1 | grep thing2
  • Bunun nedeni pipe’ın kendisi değil, aradaki grep thing1 komutunun sonucu hemen yazmak yerine buffer’da saklamasıdır
  • Program her seferinde hemen yazarsa sistem çağrıları artacağından, performans için bir miktar veriyi topladıktan sonra pipe’a veya dosyaya yazar
  • Bu örnekte grep thing1, yaklaşık 8KB çıktı birikene kadar bekleyebilir; yavaş bir günlükte bu koşul pratikte hiç gerçekleşmeyebilir

Terminal ve pipe’ta değişen çıktı yöntemi

  • tail -f file | grep thing iyi çalışır; ancak arkasına ikinci bir grep eklendiğinde çıktı durmuş gibi görünebilir
  • grep ve birçok program, stdout’un terminal olup olmadığını isatty fonksiyonuyla kontrol eder
    • stdout bir terminal ise satır buffering kullanır ve satırları hemen çıktı olarak verir
    • stdout bir pipe veya dosya ise blok buffering kullanır ve belirli bir boyutun üzerinde veri birikince çıktı verir
  • Bu yüzden grep doğrudan terminale yazdığında satırlar hemen görünür; ancak bir sonraki komuta giden pipe’a yazdığında görünmeyebilir
  • Buffer boyutu programdan programa değişir
    • grep için buffering’i libc yönetir ve libc’nin boyutu BUFSIZ değişkeniyle tanımlanır
    • glibc’deki tanımın yeri stdio.h dosyasındadır
  • Terminale yazarken 8KB’lık çıktı buffer’ı kullanmamak bir fizik kuralı değildir; program isterse böyle uygulanabilir, ancak bu oldukça tuhaf bir davranışa yakın olur

Komuttan komuta değişen buffering davranışı

  • Çıktı buffering’inin zor olmasının nedeni, hangi komutun pipe çıktısında buffering yaptığını kullanıcının hatırlamak zorunda olmasıdır
  • Çıktı buffering’i yapmayan komut örnekleri şunlardır
    • tail
    • cat
    • tee
  • Pipe’a yazarken çıktı buffering’i yapan yaygın komutlar ve bunu azaltma yöntemleri şunlardır
    • grep: --line-buffered
    • sed: -u
    • awk: fflush() fonksiyonu
    • tcpdump: -l
    • jq: -u
    • tr: -u
    • cut: buffering devre dışı bırakılamaz
  • sort gibi tüm girdiyi aldıktan sonra çalışabilen komutlarda buffering olup olmaması pratikte önemli değildir
  • Hem Mac OS hem de GNU sürümlerini test etmeye çalıştım; ancak çok fazla varyasyon olduğu için bazı hatalar olabilir

Programlama dillerinin varsayılan çıktısı da buffering yapar

  • Bazı programlama dillerindeki varsayılan print çıktısı da pipe’a yazarken buffering yapar
  • Dillere göre devre dışı bırakma yöntemleri şöyledir
    • C: setvbuf
    • Python: python -u, PYTHONUNBUFFERED=1, sys.stdout.reconfigure(line_buffering=False), print(x, flush=True)
    • Ruby: STDOUT.sync = true
    • Perl: $| = 1
  • Bu varsayılan davranış, toplu işlemede varsayılan çıktı fonksiyonlarını hızlı yapmak için tasarlanmış gibi görünüyor
  • Çıktı yöntemine göre buffering olup olmaması değişebilir
    • C++’ta cout << "hello\n" pipe’a yazarken buffering yapar
    • cout << "hello" << endl çıktıyı flush eder

Ctrl-C ve dosya yönlendirmesinde oluşan fark

  • Aşağıdaki gibi tcpdump çıktısını grepe bağlayıp -l seçeneğini unutursanız çıktı buffer’da kalabilir
    • sudo tcpdump -ni any port 53 | grep example.com
  • İdeal olarak Ctrl-Cye basıldığında tcpdumpın buffer’ı flush etmesini, grepin arama yapmasını ve eksik çıktının görünmesini bekleyebilirsiniz
  • Gerçekte programlar sonlanırken tcpdump buffer’ındaki çıktı kaybolur
  • strace ile kontrol edildiğinde grep, tcpdumptan önce SIGINT aldığı için, tcpdump flush etmeye çalışsa bile grep zaten ölmüş olabilir
  • Bir geçici çözüm olarak tcpdumpın PID’sini bulup kill -TERM $PID çalıştırırsanız, tcpdump buffer’ı flush eder ve çıktı görünebilir
  • Dosya yönlendirmesi de buffering yapar
    • sudo tcpdump -ni any port 53 > output.txt
  • Ancak dosya yönlendirmesinde, Ctrl-Cnin buffer içeriğini tamamen uçurması sorunundan farklı olarak, deneyime göre program sonlanmadan önce buffer içeriği çoğu zaman dosyaya yazılır
  • Bu davranışa her zaman güvenilip güvenilemeyeceği kesin değildir

Buffering’den kaçınmanın beş yolu

  • Hızlı biten bir programa geçmek

    • Pipe’a yavaş yazılan durumu tamamen önleyip hızlı biten bir komuta geçebilirsiniz
    • Örnek şöyledir
      • cat /some/log/file | grep thing1 | grep thing2 | tail
    • Orijinal tail -f komutuyla aynı davranış değildir, ancak karmaşık buffering sorunlarından kaçınmanızı sağlar
  • grepin satır-buffer seçeneğini kullanmak

    • grepte buffering’den kaçınmak için bir bayrak vardır
    • Örnek şöyledir
      • tail -f /some/log/file | grep --line-buffered thing1 | grep thing2
  • awk veya daha karmaşık bir grep ile birleştirmek

    • Birden fazla grep kullanılan durumlar tek bir awk ile değiştirilebilir
      • tail -f /some/log/file | awk '/thing1/ && /thing2/'
    • Ya da daha karmaşık regex kullanan bir grep yazılabilir
      • tail -f /some/log/file | grep -E 'thing1.*thing2'
    • awk da buffering yaptığı için, bu yöntemin çalışması için awkın pipeline’daki son komut olması gerekir
  • stdbuf kullanmak

    • stdbuf, libc’nin buffering’ini kapatmak için LD_PRELOAD kullanır
    • Çıktı buffering’ini kapatma örneği şöyledir
      • tail -f /some/log/file | stdbuf -o0 grep thing1 | grep thing2
    • LD_PRELOAD tabanlı çözümler gibi güvenilirliği sınırlıdır
      • Statik binary’lerde çalışmaz
      • Program libc buffering’i kullanmıyorsa çalışmayabilir
      • Mac OS’ta her zaman çalışmaz
    • İlgili açıklama olarak Harry Marr’ın How stdbuf works yazısı vardır
  • unbuffer kullanmak

    • unbuffer program, program çıktısını TTY’ymiş gibi zorlayarak normal TTY’de olduğu gibi daha az buffering yapılmasını ve renkli çıktı gibi özelliklerin kullanılmasını sağlar
    • Örnek şöyledir
      • tail -f /some/log/file | unbuffer grep thing1 | grep thing2
    • stdbufun aksine her zaman çalışır; ancak istenmeyen yan etkileri olabilir
      • Örneğin grep thing1, eşleşen sonuçlara renk ekleyebilir
    • unbuffer, expect paketinin içindedir

Sorunun çoğunlukla ortaya çıktığı durumlar ve ortam değişkeni fikri

  • Bu sorun çoğunlukla veriyi pipe’a yavaş yavaş akıtan programlarda ortaya çıkar
  • Örnekler şunlardır
    • tcpdump
    • tail -f
    • kubectl logs benzeri günlük izleme
    • Yavaş hesaplamaların çıktısı
  • Python’daki PYTHONUNBUFFERED gibi buffering’i kapatan standart bir ortam değişkeni olsa iyi olabilir
  • Bu fikir Mark Dominus’un 2018 tarihli blog yazısı ve devam yazısından geldi
  • İsim örneği olarak NO_COLOR gibi NO_BUFFER mümkün olabilir
  • Tasarımı zordur
    • NETBSD’de çok sayıda kontrol sağlayan STDBUF, STDBUF1 vb. ortam değişkenleri vardır
    • Çoğu geliştirici, görece küçük bir edge case için birden fazla ortam değişkeni uygulamak istemeyebilir
  • Çıktı buffer’ını 1 saniye gibi aralıklarla otomatik flush eden programlar olup olmadığını da merak ediyorum; ancak aklıma böyle bir program gelmiyor ve dezavantajları olabilir

Kapsam dışında bırakılanlar

  • Satır buffering ile tamamen buffer’sız çıktı arasındaki fark hariç tutuldu
  • stderr buffering ile stdout buffering arasındaki fark hariç tutuldu
  • Bu içerik yalnızca program içinde oluşan buffering konusunu ele alır
  • İşletim sisteminin TTY sürücüsü de bazen az miktarda buffering yapar
  • Pipe’a yazma durumunun dışında çıktının flush edilmesi gereken başka nedenler hariç tutuldu

1 yorum

 
GN⁺ 2024-11-30
Hacker News yorumları
  • Tamponlanmış yaklaşım neredeyse her zaman bayt eşiğine ulaşıldığında ya da en az 1 bayt varsa belirli bir süre geçtikten sonra flush eden bir “eşik veya zaman aşımı” yöntemi olmalı
    Benzer sorunları çözmek için donanım arayüzlerinde yaygın bir yöntemdir
    Bu durumda kullanıcı alanında tamponlama yapan kütüphanenin veriyi ilk kez tampona koyarken uygun bir timer ayarlaması gerekir. Zaman aşımı değeri argüman olarak alınabilir, insanın algısına göre kısa sayılacak 1~100 ms civarında seçilebilir, {bant genişliği / eşik} ile orantılı tutulabilir ya da sistem çağrısı overhead'inin toplam sürenin %0,1'ini aşmamasını sağlayacak şekilde belirlenebilir
    Bu yöntem yalnızca yazmaya değil okumaya da uygulanır. Toplu okuma veya birleştirilmiş okuma yapılıyorsa benzer bir yöntem gerekir; ancak veri kanalında “bekleyen veriyi” verimli biçimde sorgulamanın veya bildirim almanın bir yolu bulunmalı, bu yüzden daha çok kanal tasarımına bağlıdır. Donanımda interrupt coalescing gibi yöntemler yaygındır

    • Bu yönün doğru olduğunu düşünüyorum, ancak libc otomatik timer ayarlarsa beklenen davranış değişeceği için pek çok zorlu sorun doğar
      G/Ç hataları yalnızca yazma anında değil, herhangi bir anda oluşabilir; ayrıca yalnızca programın kendi timer koyduğu yerlerde veya sinyalin geldiği noktalarda değil, timer yüzünden çeşitli sistem çağrıları da kesintiye uğrayabilir
      Uygulama ve libc ikisi birden timer ayarlarsa karışıklık çıkma olasılığı da var. Güncel kernel timer API'leri eski hatırladığımdan daha iyi göründüğü için bu daha az ilgili olabilir; ama uygulama kritik bölgelerde sinyalleri kısa süreliğine engellerse G/Ç timer'ları da bundan etkilenir
      Sinyal işleme zamanı ve biçimi nedeniyle G/Ç yapısı erişimi konusunda daha dikkatli olmak gerekir
    • Böyle bir zaman aşımını şeffaf biçimde ele almak POSIX ve ISO C kısıtları altında zor görünüyor. Uygulama katmanının bir ölçüde işbirliği yapması gerekiyor gibi
    • Genel Linux alarm mekanizmaları sinyal tabanlı olduğu için yönetimi çok zordur; yeniden zamanlamak için kernel'e girmek gerektiğinden performans etkisi olabilir
      io_uring ve kullanıcı alanı timer'ları kullanıldığında çok daha iyi ölçeklenir, ancak çok sayıda hızlı küçük yazmayı desteklemek için yine de hileler gerekir. Örneğin saniyede yaklaşık 1 milyonun üzerine çıkınca timer yönetim maliyeti giderek görünür olmaya başlar; saniyede 100 milyon yazmaya kadar çıkmak için epey sıra dışı teknikler gerekmişti
    • Katılmak zor. Buradaki tamponlama zaten yapması gereken şeyi yapıyor
      Sorunun nedeni, etkileşimli olması gereken şeylerle etkileşim varsaymayan bir sözleşmenin karışması. Örneğin tail takip çıktısını pipe'a göndermek gibi
      Çözülmesi gereken gerçek bir sorun olduğunu düşünmüyorum. Donanım benzetmesi yapılacaksa bu, yağmur suyunu biriktiren ve yalnızca dolduğunda taşıyan bir su deposu olur. Hangi örneği kastettiğini bilmiyorum ama bildiğim kadarıyla donanımda zaman tabanlı flush yaygın değil
      Önerilen düzeltme sözleşmeyi çok daha karmaşık hale getiriyor
    • Öngörülebilir bir footgun'ın daha iyi olduğunu düşünüyorum. Fikir iyi ama ayrı bir flag olmalı; o zaman da varlığını bilmek gerekir
      Sorun semantiğin kendisinden çok semantiği bilmemekte
  • NIX sistemleriyle 20 yılı aşkın süredir uğraşıyor olmama rağmen bunun yaşandığını biliyorum; yine de çıktının neden gelmediğini uzun süre puzzling bulduktan sonra her seferinde ancak hatırlıyorum

  • “Son yazılar epey uzamaya başladı; tamponlama hakkında 3000 kelimelik bir yazıyı gerçekten okumak isteyen olur mu?” kısmına gelince, şahsen okumak isterim

    • Yazıya göre değişir
      Uzun yazıların arama optimizasyonu için konmuş dolgu olduğu durumlar da var bence
    • Bu tür konularda yapay zeka özeti epey yardımcı oluyor bence. Yazıyı özetletip sonra gözden geçirmek yeterli
      TLDR ve NTLDR, yani “uzun olsa da okudum” bölümleri koymak da mümkün
  • Sistem genelinde CPU her boşta kaldığında tüm tamponlar flush edilse güzel olurdu
    Tamponlama genel olarak CPU tasarrufu sağlayan bir tekniktir. CPU sonsuz olsaydı tüm tamponlar 1 bayt olurdu. Tampon, veriyi verimlilik için toplayıp toplu işleme yöntemidir
    Ama CPU boşta kaldığında “sonra yapılacak iş” kalmamalı. Kernel scheduler boşta kalır kalmaz tüm süreçlere tamponları flush et sinyali göndermeli

    • İlginç bir fikir. Ancak tüm süreçlere sinyal göndermek çok pahalı görünüyor
      O işi yaptıktan sonra bir de tampon flush etmek için sistem çağrısı yaptırmak anlamına geliyor. Kernel'in kullanıcı alanı tamponlarını bilebilmesini sağlayıp boşta kaldığında veriyi doğrudan oradan alacağı bir mekanizma eklenebilir belki
      Bu bir ölçüde io_uring https://man7.org/linux/man-pages/man3/io_uring_register_buff... gibi bir şey mi acaba
    • Harika bir fikir ama hepsini bir anda yapmamak daha iyi görünüyor. Ayrıca düşük güç sistemlerinde, tahmine dayalı olarak uyuyan süreçleri uyandırıp verimliliği düşürebilir
  • Bu yazı tamponsuz ve satır tamponlama diye iki farklı şeyi karıştırıyor
    Tamponsuz çalışma gereksiz yere performansı kötüleştirir ve birden fazla kaynak aynı pipe'a yazıyorsa hatalı çıktı üretebilir. Yeterince uzun satırlar zaten karışacaktır; ancak gerçek dünyadaki çıktı satırlarının çoğu, biçimlendirme/kontrol karakterleri ve yardımcı düzlem karakterleri dahil olsa bile 4096 bayttan kısadır
    Satır tamponlama terminalin varsayılanıdır ve pipe'larda da genellikle istenen davranıştır. Her komutu stdbuf -oL -eL altında çalıştırmak yeterli. Satır içi güncelleme isteyen nadir programlar zaten manuel flush yapmak zorunda olduğundan burada da doğru çalışır
    stdbufun gerçekte yaptığı şey şöyle görülebilir:
    env -i \command -v stdbuf` -oL -eL `command -v env``

  • Bu sorun hakkında daha önce yazmıştım: https://world-playground-deceit.net/blog/2024/09/bourne_shel...
    Arabelleğe almayan komutlar konusunda bu uygulamaya bağlı olabilir ya da cat söz konusuysa yanlış da olabilir. https://pubs.opengroup.org/onlinepubs/9799919799/utilities/c... ve -uya bakılabilir. POSIX’in bunu yönetmek için resmî bir yol koymamış olması büyük bir sıkıntı.
    Bahsedilmeyen bir şey de girdi arabelleğe alma; şu tür garip sonuçlar üretiyor:
    $ seq 5 | { v1=$(head -1); v2=$(head -1); printf '%s=%s\n' v1 "$v1" v2 "$v2"; }
    v1=1
    v2=
    Bu durumda çözüm stdbuf -i0 head -1 kullanmak.

    • Pipe veya socketpair gibi yerlerden okuyan bir sürecin, yazan sürece böyle bir kısıtı dayatabileceğini sanmıyorum. ptrace() gibi ağır hack’ler kullanmak hariç.
      Pipe arabellek boyutunu ayarlamak mümkün olabilir, ama standart C giriş/çıkışının buna uyması gerektiğine dair bir teamül bilmiyorum.
      Her hâlükârda bu durumda stdbuf yardımcı olmuyor gibi:
      $ ./a | stdbuf -i0 -- cat
      #include
      #include
      int main(void) {
      for (;;) {
      printf("n");
      usleep(100000);
      }
      }
  • Arabelleklerin olmasının iyi nedenleri var. Ekrana çıktı basmak, arabelleğe yazmaya kıyasla nispeten çok yavaş.
    Karakterleri tek tek yazdırmak son derece verimsiz.
    Bu eski bir sorun ve UART ile uğraşırken sıkça karşımıza çıkar. Olası çözümler birkaç tane: çıktının sonunu yeni satır gibi özel bir karakterle işaretleyen satır tabanlı yöntem, 8KB gibi bir uzunluğa ulaşana kadar bekleyen uzunluk tabanlı yöntem, X milisaniyede bir çıktı veren zaman tabanlı yöntem.
    Her yöntemin artıları ve eksileri var; hangisinin en iyi olduğu uygulamaya göre değişir. Yazıda bazı programların arabelleğe alma kullanmadığı söylenen kısmın yanlış olduğunu düşünüyorum. O programlar sadece bariz uzunluk tabanlı yöntemi kullanmıyor.

    • Kısıtları arayüzün bir-iki katman üstünde bilen bir yaklaşım mümkün olduğunda en iyi çalışır.
      Satır tabanlı yaklaşım buna örnek; ama hangi karakterin kullanılacağı konusunda uzlaşma gerekir. Genelde bu yeni satırdır.
    • Mesele yalnızca gerçek yazma işlemini backend’in yapmasının maliyeti değil. /dev/nulla bu kadar çok sistem çağrısı yapmak bile performansı ciddi biçimde öldürebilir.
  • Unix’i 35 yılı aşkın süredir kullanıyorum ama bunun nasıl çalıştığını hiçbir zaman tam olarak anlamamıştım.
    Birden çok sistem ve bileşene yayılan arabelleğe alma davranışını bütünlüklü şekilde açıklaması hoşuma gitti; kesinlikle bir şeyler öğrendim.

  • “Pipe’ta Ctrl-C’ye basınca arabellek içeriği kaybolur” kısmına gelince, çoğu programın SIGINT aldığında arabelleği flush edeceğini sanıyorum.
    Ancak kabukta böyle çalışması için SIGINT’in yalnızca pipeline’daki ilk programa iletilmesi gerekir; muhtemelen gerçek davranış böyle değildir.

    • Hatırladığım kadarıyla son süreç sigint alır, geri kalanlar sigpipe alır.