2 điểm bởi GN⁺ 2024-11-30 | 1 bình luận | Chia sẻ qua WhatsApp
  • Các pipeline nối nhiều lệnh với đầu ra đi vào chậm như tail -f /some/log/file | grep thing1 | grep thing2 có thể trông như bị treo, nhưng thực ra không phải vậy: lệnh ở giữa đang tích đầu ra vào buffer
  • grep và nhiều chương trình kiểm tra stdout có phải terminal hay không bằng isatty; nếu là terminal thì dùng line buffering, còn nếu là pipe hay file thì thường dùng block buffering khoảng 8KB
  • tail, cat, tee là ví dụ các lệnh không buffer đầu ra, nhưng các tùy chọn giảm buffering như grep --line-buffered, sed -u, tcpdump -l, jq -u, tr -u lại khác nhau theo từng lệnh
  • Nếu ngắt pipeline bằng Ctrl-C, đầu ra đang nằm trong buffer của các chương trình như tcpdump có thể bị mất; nếu kết thúc bằng kill -TERM $PID thì buffer có thể được flush và đầu ra sẽ hiện ra
  • Giải pháp thực tế gồm thay bằng lệnh kết thúc nhanh, dùng grep --line-buffered, gộp thành một awk duy nhất hoặc grep phức tạp hơn, stdbuf, unbuffer, nhưng cần kiểm tra điều kiện hoạt động và tác dụng phụ của từng cách

Vì sao pipe trông như bị treo

  • Khi các dòng được thêm chậm vào file log, pipeline sau có thể không hiển thị đầu ra dù vẫn có kết quả khớp
    • tail -f /some/log/file | grep thing1 | grep thing2
  • Nguyên nhân không phải bản thân pipe, mà là grep thing1 ở giữa không ghi kết quả ngay mà lưu trong buffer
  • Nếu chương trình ghi ra ngay mỗi lần, số lần gọi hệ thống sẽ tăng lên, nên để tối ưu hiệu năng nó thường gom một lượng dữ liệu nhất định rồi mới ghi vào pipe hoặc file
  • Trong ví dụ này, grep thing1 có thể đợi đến khi tích được khoảng 8KB đầu ra, và với log chậm thì điều kiện đó có thể gần như không bao giờ xảy ra

Cách xuất đầu ra khác nhau giữa terminal và pipe

  • tail -f file | grep thing hoạt động ổn, nhưng nếu thêm grep thứ hai phía sau thì đầu ra có thể trông như bị treo
  • grep và nhiều chương trình kiểm tra stdout có phải terminal hay không bằng hàm isatty
    • Nếu stdout là terminal thì dùng line buffering và xuất ngay theo từng dòng
    • Nếu stdout là pipe hoặc file thì dùng block buffering và chỉ xuất khi dữ liệu tích đủ một kích thước nhất định
  • Vì vậy nếu grep ghi trực tiếp ra terminal thì dòng sẽ hiện ngay, còn nếu ghi vào pipe nối sang lệnh tiếp theo thì có thể không thấy gì
  • Kích thước buffer khác nhau tùy chương trình
    • Với grep, libc xử lý buffering, và kích thước của libc được định nghĩa bằng biến BUFSIZ
    • Vị trí định nghĩa trong glibc nằm ở stdio.h
  • Việc không dùng buffer đầu ra 8KB khi ghi ra terminal không phải là quy luật vật lý; chương trình hoàn toàn có thể được viết theo cách đó nếu muốn, nhưng đó sẽ là hành vi khá kỳ lạ

Hành vi buffering khác nhau theo từng lệnh

  • Lý do khiến buffering đầu ra rắc rối là người dùng phải nhớ lệnh nào sẽ buffer khi ghi vào pipe
  • Một số lệnh không buffer đầu ra gồm:
    • tail
    • cat
    • tee
  • Các lệnh thường buffer đầu ra khi ghi vào pipe và cách giảm nhẹ gồm:
    • grep: --line-buffered
    • sed: -u
    • awk: hàm fflush()
    • tcpdump: -l
    • jq: -u
    • tr: -u
    • cut: không thể tắt buffering
  • Với các lệnh như sort, vốn chỉ có thể xử lý sau khi đã nhận toàn bộ đầu vào, việc có buffering hay không về thực chất không quan trọng
  • Tác giả đã cố thử cả bản Mac OS và GNU, nhưng do có nhiều biến thể nên có thể vẫn có một vài sai sót

Đầu ra mặc định của ngôn ngữ lập trình cũng buffer

  • Đầu ra print mặc định của một số ngôn ngữ lập trình cũng buffer khi ghi vào pipe
  • Cách tắt theo từng ngôn ngữ gồm:
    • C: setvbuf
    • Python: python -u, PYTHONUNBUFFERED=1, sys.stdout.reconfigure(line_buffering=False), print(x, flush=True)
    • Ruby: STDOUT.sync = true
    • Perl: $| = 1
  • Hành vi mặc định này có vẻ là một thiết kế nhằm làm cho hàm xuất mặc định chạy nhanh trong các tác vụ batch
  • Việc có buffering hay không cũng có thể thay đổi tùy cách xuất
    • Trong C++, cout << "hello\n" sẽ buffer khi ghi vào pipe
    • cout << "hello" << endl sẽ flush đầu ra

Khác biệt với Ctrl-C và chuyển hướng vào file

  • Nếu nối đầu ra tcpdump vào grep như sau và quên -l, đầu ra có thể vẫn nằm trong buffer
    • sudo tcpdump -ni any port 53 | grep example.com
  • Về lý tưởng, khi nhấn Ctrl-C, ta có thể mong tcpdump flush buffer và grep xử lý để hiện ra phần đầu ra còn thiếu
  • Nhưng trên thực tế, khi các chương trình kết thúc, đầu ra đang nằm trong buffer của tcpdump sẽ bị mất
  • Khi kiểm tra bằng strace, grep nhận SIGINT trước tcpdump, nên dù tcpdump có cố flush thì grep có thể đã chết rồi
  • Một cách vòng tránh là tìm PID của tcpdump rồi chạy kill -TERM $PID; khi đó tcpdump có thể flush buffer và đầu ra sẽ hiện ra
  • Chuyển hướng vào file cũng có buffering
    • sudo tcpdump -ni any port 53 > output.txt
  • Tuy vậy, khác với chuyện Ctrl-C có thể làm mất sạch nội dung trong buffer, với chuyển hướng vào file thì theo kinh nghiệm buffer thường vẫn được ghi ra file trước khi chương trình thoát
  • Dù vậy, không rõ có thể luôn tin cậy vào hành vi này hay không

Năm cách tránh buffering

  • Đổi sang chương trình kết thúc nhanh

    • Có thể tránh hẳn tình huống ghi chậm vào pipe bằng cách đổi sang lệnh kết thúc nhanh
    • Ví dụ:
      • cat /some/log/file | grep thing1 | grep thing2 | tail
    • Nó không giống hệt hành vi của tail -f ban đầu, nhưng tránh được vấn đề buffering phức tạp
  • Dùng tùy chọn line buffer của grep

    • grep có cờ để tránh buffering
    • Ví dụ:
      • tail -f /some/log/file | grep --line-buffered thing1 | grep thing2
  • Gộp bằng awk hoặc grep phức tạp hơn

    • Những trường hợp dùng nhiều grep có thể đổi thành một awk duy nhất
      • tail -f /some/log/file | awk '/thing1/ && /thing2/'
    • Hoặc viết bằng grep với regex phức tạp hơn
      • tail -f /some/log/file | grep -E 'thing1.*thing2'
    • awk cũng buffer, nên cách này chỉ hoạt động nếu awklệnh cuối cùng trong pipeline
  • Dùng stdbuf

    • stdbuf dùng LD_PRELOAD để tắt buffering của libc
    • Ví dụ tắt buffering đầu ra:
      • tail -f /some/log/file | stdbuf -o0 grep thing1 | grep thing2
    • Giống các giải pháp dựa trên LD_PRELOAD, độ tin cậy của nó có giới hạn
      • Không hoạt động với binary tĩnh
      • Có thể không hoạt động nếu chương trình không dùng buffering của libc
      • Không phải lúc nào cũng hoạt động trên Mac OS
    • Bài viết liên quan là How stdbuf works của Harry Marr
  • Dùng unbuffer

    • unbuffer program ép chương trình coi đầu ra như đang ghi vào TTY, nhờ đó nó buffer ít hơn như trên TTY thông thường và cũng có thể bật màu đầu ra v.v.
    • Ví dụ:
      • tail -f /some/log/file | unbuffer grep thing1 | grep thing2
    • Khác với stdbuf, cách này luôn hoạt động nhưng có thể gây tác dụng phụ không mong muốn
      • Ví dụ grep thing1 có thể thêm màu vào kết quả khớp
    • unbuffer nằm trong gói expect

Những tình huống thường lộ ra vấn đề và ý tưởng về biến môi trường

  • Vấn đề này chủ yếu xuất hiện ở các chương trình đẩy dữ liệu chậm qua pipe
  • Ví dụ gồm:
    • tcpdump
    • tail -f
    • Kiểu theo dõi log như kubectl logs
    • Đầu ra của các phép tính chậm
  • Sẽ rất hay nếu có một biến môi trường chuẩn để tắt buffering như PYTHONUNBUFFERED của Python
  • Ý tưởng này đến từ bài blog năm 2018 và bài tiếp theo của Mark Dominus
  • Một tên ví dụ có thể là NO_BUFFER, tương tự như NO_COLOR
  • Nhưng thiết kế việc này không hề đơn giản
  • Tác giả cũng tự hỏi liệu có chương trình nào tự động flush buffer theo chu kỳ như 1 giây hay không, nhưng không nghĩ ra cái tên nào và cách này cũng có thể có nhược điểm

Phạm vi không bàn đến

  • Không đề cập sự khác biệt giữa line buffering và đầu ra hoàn toàn không buffer
  • Không đề cập sự khác biệt giữa buffering của stderr và stdout
  • Nội dung này chỉ nói về buffering xảy ra bên trong chương trình
  • Driver TTY của hệ điều hành đôi khi cũng có một chút buffering
  • Không bàn tới các lý do khác cần flush đầu ra ngoài trường hợp ghi vào pipe

1 bình luận

 
GN⁺ 2024-11-30
Các ý kiến trên Hacker News
  • Cách tiếp cận có đệm hầu như luôn nên là kiểu “ngưỡng hoặc timeout”: flush khi đạt ngưỡng byte, hoặc sau một khoảng thời gian nếu có dù chỉ tối thiểu 1 byte
    Đây là cách phổ biến trong các giao diện phần cứng để giải quyết những vấn đề tương tự
    Trong trường hợp này, thư viện buffer ở user space cần đặt một timer phù hợp khi lần đầu đưa dữ liệu vào buffer. Giá trị timeout có thể được nhận qua tham số, đặt ở mức 1–100ms đủ ngắn với cảm nhận của con người, đặt tỉ lệ với {băng thông / ngưỡng}, hoặc sao cho overhead của system call không vượt quá 0,1% tổng thời gian
    Cách này áp dụng không chỉ cho ghi mà cả cho đọc. Nếu đọc theo lô hoặc đọc gộp thì cũng cần cơ chế tương tự, nhưng kênh dữ liệu phải có cách truy vấn hoặc thông báo “dữ liệu đang chờ” một cách hiệu quả, nên phụ thuộc nhiều hơn vào thiết kế kênh. Trong phần cứng, các cách như gộp interrupt là phổ biến

    • Tôi nghĩ hướng này đúng, nhưng nếu libc thiết lập timer tự động thì hành vi kỳ vọng sẽ thay đổi, gây ra nhiều vấn đề khó xử
      Lỗi I/O có thể xảy ra không chỉ tại thời điểm ghi mà gần như bất cứ lúc nào, và nhiều system call có thể bị timer ngắt, không chỉ ở nơi chương trình tự đặt timer hay nơi tín hiệu được gửi đến
      Nếu cả ứng dụng lẫn libc đều đặt timer, khả năng gây lẫn lộn cũng có thể xảy ra. API timer của kernel ngày nay có vẻ tốt hơn ký ức cũ của tôi nên điều này có thể ít liên quan hơn, nhưng nếu ứng dụng tạm chặn tín hiệu trong một đoạn quan trọng thì timer I/O cũng bị ảnh hưởng
      Do thời điểm và cách xử lý tín hiệu, cần cẩn thận hơn khi truy cập cấu trúc I/O
    • Việc xử lý timeout như vậy một cách trong suốt có vẻ sẽ khó dưới các ràng buộc của POSIX và ISO C. Có lẽ cần một mức độ hợp tác nhất định từ tầng ứng dụng
    • Alarm thông thường trên Linux dựa trên tín hiệu nên rất khó quản lý, và để reschedule phải đi vào kernel nên có thể ảnh hưởng hiệu năng
      Dùng io_uring và timer ở user space thì mở rộng tốt hơn nhiều, nhưng để hỗ trợ nhiều lần ghi nhỏ rất nhanh thì vẫn cần mẹo. Ví dụ, khi vượt khoảng 1 triệu lần mỗi giây, chi phí quản lý timer bắt đầu hiện rõ dần, và để đạt tới 100 triệu lần ghi mỗi giây thì từng cần những kỹ thuật khá kỳ lạ
    • Khó đồng ý. Buffering ở đây đang làm đúng việc vốn dĩ nó phải làm
      Nguyên nhân vấn đề nằm ở việc trộn lẫn những thứ đáng ra phải có tính tương tác với một hợp đồng không giả định tính tương tác. Ví dụ là trường hợp gửi output theo dõi của tail qua pipe
      Tôi không nghĩ có vấn đề thực sự nào cần giải quyết. Nếu dùng ví dụ phần cứng thì nó giống bồn chứa nước mưa, chỉ chuyển đi khi đã đầy. Không biết bạn đang nghĩ đến ví dụ nào, nhưng theo những gì tôi biết thì trong phần cứng, flush dựa trên thời gian không phổ biến
      Sửa đổi được đề xuất sẽ khiến hợp đồng phức tạp hơn rất nhiều
    • Tôi nghĩ một footgun có thể dự đoán được thì tốt hơn. Ý tưởng hay, nhưng nên là một flag riêng, và như vậy thì phải biết đến sự tồn tại của nó
      Vấn đề không nằm ở bản thân ngữ nghĩa, mà ở việc không biết ngữ nghĩa đó
  • Dù đã làm việc với các hệ thống NIX hơn 20 năm và biết chuyện này có thể xảy ra, mỗi lần tôi vẫn phải puzzling một lúc vì sao không có output rồi mới nhớ ra

  • Về đoạn “gần đây các bài viết đang khá dài, liệu có ai thật sự muốn đọc một bài 3000 từ về buffering không”, cá nhân tôi thì muốn đọc

    • Còn tùy bài
      Tôi nghĩ có trường hợp bài dài dòng chỉ là phần thừa thãi để tối ưu hóa tìm kiếm
    • Ở những phần như vậy, tóm tắt bằng AI khá hữu ích. Có thể nhờ tóm tắt bài rồi xem lại
      Cũng có thể đặt các mục TLDR và NTLDR, tức là mục “dù dài nhưng đã đọc”
  • Tôi ước mọi buffer được flush mỗi khi CPU toàn hệ thống chuyển sang trạng thái idle
    Buffering chủ yếu là kỹ thuật để tiết kiệm CPU. Nếu CPU là vô hạn, mọi buffer sẽ chỉ có 1 byte. Buffer là cách gom dữ liệu để xử lý hàng loạt nhằm tăng hiệu quả
    Nhưng khi CPU idle thì không nên còn “việc để làm sau”. Ngay khi kernel scheduler chuyển sang idle, nó nên gửi tín hiệu hãy flush buffer tới mọi tiến trình

    • Ý tưởng thú vị. Tuy nhiên gửi tín hiệu tới mọi tiến trình nghe cực kỳ tốn kém
      Tức là làm xong tất cả việc đó rồi lại khiến chúng thực hiện system call để flush buffer. Có lẽ có thể thêm cơ chế để kernel biết buffer ở user space và trực tiếp lấy dữ liệu từ đó khi idle
      Không biết điều này có phần nào giống io_uring https://man7.org/linux/man-pages/man3/io_uring_register_buff... không
    • Ý tưởng hay, nhưng có vẻ không nên làm tất cả cùng lúc. Và trên các hệ thống tiết kiệm điện, việc đánh thức các tiến trình đang ngủ theo kiểu suy đoán cũng có thể làm giảm hiệu quả
  • Bài này đang nhầm lẫn giữa hai thứ khác nhau là không bufferbuffer theo dòng
    Không buffer làm hiệu năng tệ đi một cách không cần thiết, và có thể tạo output sai khi nhiều nguồn cùng ghi vào một pipe. Các dòng đủ dài thì dù sao cũng sẽ bị trộn, nhưng phần lớn dòng output thực tế đều ngắn hơn 4096 byte, kể cả khi có ký tự định dạng/điều khiển và ký tự ở supplementary plane
    Buffer theo dòng là mặc định của terminal, và thường cũng là hành vi mong muốn với pipe. Chỉ cần chạy từng lệnh dưới stdbuf -oL -eL. Những chương trình hiếm hoi muốn cập nhật trong cùng một dòng vốn đã phải flush thủ công, nên ở đây cũng sẽ hoạt động đúng
    Có thể xem việc stdbuf thực sự làm như sau:
    env -i \command -v stdbuf` -oL -eL `command -v env``

  • Trước đây tôi từng viết về vấn đề này: https://world-playground-deceit.net/blog/2024/09/bourne_shel...
    Với các lệnh không dùng bộ đệm thì điều này phụ thuộc vào implementation, hoặc trong trường hợp của cat có thể là sai. Xem https://pubs.opengroup.org/onlinepubs/9799919799/utilities/c...-u. Việc POSIX không đưa vào một cách chính thức để quản lý chuyện này thật sự rất phiền
    Một thứ chưa được nhắc đến là buffering đầu vào, và nó cho ra kết quả kỳ lạ như sau:
    $ seq 5 | { v1=$(head -1); v2=$(head -1); printf '%s=%s\n' v1 "$v1" v2 "$v2"; }
    v1=1
    v2=
    Trong trường hợp này, cách giải quyết là dùng stdbuf -i0 head -1

    • Tôi không nghĩ một tiến trình đang đọc từ những nơi như pipe hay socketpair có thể ép ràng buộc như vậy lên tiến trình đang ghi, trừ khi dùng các hack nặng như ptrace()
      Có thể điều chỉnh kích thước pipe buffer, nhưng tôi không biết có quy ước nào nói rằng I/O chuẩn C phải tuân theo điều đó
      Dù sao thì trong trường hợp này stdbuf có vẻ không giúp được gì:
      $ ./a | stdbuf -i0 -- cat
      #include
      #include
      int main(void) {
      for (;;) {
      printf("n");
      usleep(100000);
      }
      }
  • Có lý do chính đáng để tồn tại buffer. Việc in output ra màn hình tương đối rất chậm so với ghi vào buffer
    Xuất từng ký tự một cực kỳ kém hiệu quả
    Đây là một vấn đề cũ, thường gặp khi xử lý UART. Có nhiều hướng giải quyết khả dĩ: cách dựa trên dòng, dùng ký tự đặc biệt như newline để đánh dấu kết thúc output; cách dựa trên độ dài, đợi đến khi đạt một độ dài như 8KB; hoặc cách dựa trên thời gian, output mỗi X mili-giây
    Mỗi cách đều có ưu và nhược điểm, và cái nào tối ưu phụ thuộc vào ứng dụng. Tôi cho rằng phần trong bài nói một số chương trình không dùng buffering là sai. Các chương trình đó chỉ là không dùng cách dựa trên độ dài rõ ràng mà thôi

    • Cách này hoạt động tốt nhất khi có thể biết các ràng buộc ở một hoặc hai tầng phía trên interface
      Cách tiếp cận dựa trên dòng là một ví dụ như vậy, nhưng cần có thỏa thuận về việc dùng ký tự nào. Thường là newline
    • Không chỉ là vấn đề chi phí để backend xử lý thao tác ghi thực sự. Chỉ riêng việc thực hiện nhiều system call như vậy tới /dev/null cũng có thể làm hiệu năng giảm mạnh
  • Tôi đã dùng Unix hơn 35 năm nhưng chưa bao giờ hiểu hoàn toàn cách chuyện này vận hành
    Bài giải thích tổng thể về hành vi buffering trải qua nhiều hệ thống và thành phần rất hay, và chắc chắn tôi đã học được điều gì đó

  • Về đoạn “nhấn Ctrl-C trong pipe thì nội dung buffer biến mất”, tôi nghĩ hầu hết chương trình sẽ flush buffer khi nhận SIGINT
    Tuy nhiên để shell hoạt động như vậy, có lẽ nó phải chỉ chuyển SIGINT cho chương trình đầu tiên trong pipeline, mà có vẻ thực tế không phải như thế

    • Tôi nhớ là tiến trình cuối nhận sigint, còn các tiến trình còn lại nhận sigpipe