- 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 thing2có 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 grepvà nhiều chương trình kiểm tra stdout có phải terminal hay không bằngisatty; 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 8KBtail,cat,teelà 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 -ulạ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ưtcpdumpcó thể bị mất; nếu kết thúc bằngkill -TERM $PIDthì 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ộtawkduy nhất hoặcgrepphứ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 thing1có 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 thinghoạt động ổn, nhưng nếu thêmgrepthứ hai phía sau thì đầu ra có thể trông như bị treogrepvà nhiều chương trình kiểm tra stdout có phải terminal hay không bằng hàmisatty- 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
grepghi 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ếnBUFSIZ - Vị trí định nghĩa trong glibc nằm ở stdio.h
- Với
- 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:
tailcattee
- Các lệnh thường buffer đầu ra khi ghi vào pipe và cách giảm nhẹ gồm:
grep:--line-bufferedsed:-uawk: hàmfflush()tcpdump:-ljq:-utr:-ucut: 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
printmặ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
- C:
- 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" << endlsẽ flush đầu ra
- Trong C++,
Khác biệt với Ctrl-C và chuyển hướng vào file
- Nếu nối đầu ra
tcpdumpvàogrepnhư sau và quên-l, đầu ra có thể vẫn nằm trong buffersudo tcpdump -ni any port 53 | grep example.com
- Về lý tưởng, khi nhấn
Ctrl-C, ta có thể mongtcpdumpflush buffer vàgrepxử 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
tcpdumpsẽ bị mất - Khi kiểm tra bằng
strace,grepnhậnSIGINTtrướctcpdump, nên dùtcpdumpcó cố flush thìgrepcó thể đã chết rồi - Một cách vòng tránh là tìm PID của
tcpdumprồi chạykill -TERM $PID; khi đótcpdumpcó 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-Ccó 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 -fban đầu, nhưng tránh được vấn đề buffering phức tạp
-
Dùng tùy chọn line buffer của
grepgrepcó cờ để tránh buffering- Ví dụ:
tail -f /some/log/file | grep --line-buffered thing1 | grep thing2
-
Gộp bằng
awkhoặcgrepphức tạp hơn- Những trường hợp dùng nhiều
grepcó thể đổi thành mộtawkduy nhấttail -f /some/log/file | awk '/thing1/ && /thing2/'
- Hoặc viết bằng
grepvới regex phức tạp hơntail -f /some/log/file | grep -E 'thing1.*thing2'
awkcũng buffer, nên cách này chỉ hoạt động nếuawklà lệnh cuối cùng trong pipeline
- Những trường hợp dùng nhiều
-
Dùng
stdbufstdbufdùngLD_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
unbufferunbuffer 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 thing1có thể thêm màu vào kết quả khớp
- Ví dụ
unbuffernằm trong góiexpect
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:
tcpdumptail -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ư
PYTHONUNBUFFEREDcủ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
- NETBSD có các biến môi trường như
STDBUF,STDBUF1cung cấp nhiều mức điều khiển - Phần lớn lập trình viên có thể sẽ không muốn triển khai thêm nhiều biến môi trường chỉ để xử lý một edge case tương đối nhỏ
- NETBSD có các biến môi trường như
- 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
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 gianCá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
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
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ạ
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
tailqua pipeTô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
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
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
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ứ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
Bài này đang nhầm lẫn giữa hai thứ khác nhau là không buffer và buffer 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 đúngCó thể xem việc
stdbufthự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
catcó thể là sai. Xem https://pubs.opengroup.org/onlinepubs/9799919799/utilities/c... và-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ềnMộ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=1v2=Trong trường hợp này, cách giải quyết là dùng
stdbuf -i0 head -1socketpaircó 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
stdbufcó vẻ không giúp được gì:$ ./a | stdbuf -i0 -- cat#include#includeint 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 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
/dev/nullcũng có thể làm hiệu năng giảm mạnhTô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ế
sigint, còn các tiến trình còn lại nhậnsigpipe