Ở bài về pipe ta thấy pipe truyền dữ liệu giữa hai tiến trình rất nhanh khi ghi lô lớn. Nhưng pipe chỉ chảy một chiều và chỉ là một dòng byte. Khi cần hai chiều, hay cần giữ ranh giới từng thông điệp, người ta chuyển sang Unix socket (AF_UNIX). Câu hỏi tự nhiên: một socket — thứ nghe như "để nói chuyện qua mạng" — có chậm hơn pipe khi dùng trong cùng một máy không? Tôi đo cả hai trong container, và vấp một con socket làm treo hẳn chương trình của mình.
Unix socket khác gì socket mạng
Điều đầu tiên cần gỡ nhầm lẫn: AF_UNIX không đi qua mạng chút nào. Một socket TCP (AF_INET) gói dữ liệu vào segment TCP, thêm địa chỉ IP, tính tổng kiểm, đi qua cả chồng giao thức mạng — kể cả khi hai đầu nằm trên localhost. Còn AF_UNIX chỉ là một kênh nằm hoàn toàn trong nhân, giữa hai tiến trình trên cùng máy: nhân chép dữ liệu thẳng từ tiến trình này sang tiến trình kia, không segment, không IP, không tổng kiểm, không định tuyến. Về cơ chế vận chuyển, nó giống pipe hơn là giống TCP.
Có hai kiểu Unix socket, và chúng khác nhau ở ngữ nghĩa chứ không chỉ tốc độ:
SOCK_STREAM: một dòng byte hai chiều, giống pipe nhưng đi được cả hai chiều. Nó gộp các lần ghi lại — bạn ghi ba lần thì bên đọc có thể nhận về một khối liền, ranh giới giữa các lần ghi biến mất. Khi bên ghi đóng, bên đọc nhậnread()trả về 0 (hết luồng).SOCK_DGRAM: giữ nguyên ranh giới từng thông điệp — mỗi lần ghi là một gói, bên đọc nhận đúng từng gói riêng biệt. Nhưng nó không có khái niệm "hết luồng" rõ ràng.
Tôi dùng socketpair() tạo một cặp socket đã nối sẵn, ghim tiến trình vào một lõi bằng taskset, rồi đo thông lượng (ghi lô 64KB) và độ trễ khứ hồi (ping-pong 1 byte), so trực tiếp với pipe.
Đo: socket không hề chậm hơn pipe
Kết quả bác thẳng cái định kiến "socket thì chậm":
| Kênh | Thông lượng (lô 64KB) | Độ trễ khứ hồi (1 byte) |
|---|---|---|
| pipe | 10791 MB/s | 1184 ns |
unix SOCK_STREAM |
12597 MB/s | 1445 ns |
Unix socket kiểu stream thậm chí có thông lượng nhỉnh hơn pipe (12597 so với 10791 MB/s), còn độ trễ khứ hồi chỉ chậm hơn chút xíu (1445 so với 1184 nano giây) — cả hai đều cùng một bậc độ lớn. Điều này hợp lý một khi ta nhớ rằng AF_UNIX không đi qua mạng: cả pipe lẫn Unix socket rốt cuộc đều là nhân chép bộ nhớ từ tiến trình này sang tiến trình kia. Cái tên "socket" khiến người ta tưởng có phí giao thức mạng, nhưng phí đó không tồn tại với AF_UNIX. Chọn pipe hay Unix socket vì lý do tính năng (một chiều hay hai chiều, dòng byte hay giữ ranh giới), không phải vì tốc độ.
Còn SOCK_DGRAM thì tôi định đo thông lượng của nó nữa — và đó là lúc mọi chuyện hỏng.
Một lần tôi đo hớ: một con socket làm treo chương trình
Tôi viết vòng đọc dữ liệu theo cách kinh điển, đúng như khi đọc pipe:
while ((n = read(fd, buf, sizeof buf)) > 0) {
/* xử lý n byte */
}
Vòng này chạy hoàn hảo với pipe và với SOCK_STREAM: khi bên ghi đóng đầu của nó, read() trả về 0, vòng lặp kết thúc gọn gàng. Tôi bê nguyên nó sang SOCK_DGRAM — và chương trình treo cứng, chờ vô tận. Lần đầu tôi tưởng mình bị deadlock ở đâu đó, ngồi thêm strace mới thấy: tiến trình đang kẹt trong một lệnh read không bao giờ trả về.
Nguyên nhân là một điểm ngữ nghĩa tôi đã quên: socket kiểu datagram không báo "hết luồng" khi bên kia đóng như byte-stream. Với SOCK_STREAM (và pipe), đóng đầu ghi là một sự kiện rõ ràng khiến read() trả 0. Với SOCK_DGRAM, không có "luồng" để mà kết thúc — chỉ có các gói rời rạc; đọc hết gói rồi thì read() cứ nằm chờ gói tiếp theo mãi mãi. Đây chính là cái bẫy "hai công cụ trông giống nhau nhưng ngữ nghĩa khác nhau": cùng gọi read(), cùng trên một file descriptor, nhưng một cái có điểm dừng còn cái kia thì không.
Cách sửa: với datagram, đừng trông vào EOF — phải dừng theo số byte hoặc số gói đã biết trước, hoặc dùng một gói "kết thúc" quy ước, hoặc đọc không chặn (MSG_DONTWAIT) rồi xử lý khi hết. Tôi bỏ luôn phép đo thông lượng cho SOCK_DGRAM (nó không phải thứ datagram sinh ra để làm — đổ một dòng dữ liệu lớn qua datagram là đi ngược công dụng của nó) và thay bằng một phép đo đúng chất nó hơn: ranh giới thông điệp.
Đo ranh giới: nơi hai kiểu thật sự khác nhau
Tôi cho bên gửi ghi ba lần: 3 byte, rồi 2 byte, rồi 4 byte. Bên nhận đọc với một buffer lớn và xem nó nhận về những gì:
SOCK_STREAM: 1 lần đọc = 9 byte "AAABBCCCC" (gộp cả ba, mất ranh giới)
SOCK_DGRAM : 3 lần đọc = "AAA", "BB", "CCCC" (giữ nguyên ba gói)
Đây mới là khác biệt thật sự giữa hai kiểu, và nó chẳng liên quan gì tới tốc độ. SOCK_STREAM coi mọi thứ là một dòng byte liên tục — ba lần ghi của bạn chảy vào chung một dòng, bên đọc gom được bao nhiêu tùy lúc, không có cách nào biết "chỗ này là hết thông điệp thứ nhất". SOCK_DGRAM giữ từng gói nguyên vẹn: ghi ba lần thì đọc đúng ba lần, mỗi lần trọn một thông điệp. Nếu dữ liệu của bạn có cấu trúc bản ghi (mỗi thông điệp là một đơn vị có nghĩa), datagram cho bạn ranh giới miễn phí; nếu bạn tự lo khung (framing) rồi thì stream đơn giản hơn.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: đừng tránh Unix socket vì sợ "phí socket". Với giao tiếp giữa các tiến trình trên cùng một máy, AF_UNIX nhanh ngang pipe mà lại linh hoạt hơn — hai chiều, có kiểu datagram, và (điều nhiều hệ thống dựa vào) truyền được cả file descriptor giữa các tiến trình qua SCM_RIGHTS, thứ pipe không làm được. Rất nhiều phần mềm bạn dùng hằng ngày nói chuyện qua Unix socket: Docker (/var/run/docker.sock), PostgreSQL cục bộ, systemd, máy chủ hiển thị. Chúng chọn AF_UNIX chính vì nó vừa nhanh vừa cho những tính năng đó.
Hệ quả thứ hai: stream hay datagram là chọn ngữ nghĩa, không phải chọn tốc độ. Câu hỏi đúng không phải "cái nào nhanh hơn" mà là "dữ liệu của tôi có ranh giới thông điệp cần giữ không?". Cần một luồng byte liên tục (truyền file, một dòng lệnh) thì stream; cần từng bản ghi rời rạc, đọc trọn gói mỗi lần thì datagram. Chọn sai kiểu dẫn tới đúng loại lỗi tôi vừa vấp: hoặc bạn tự phải cắt khung trên một dòng byte đã gộp, hoặc bạn chờ một EOF không bao giờ tới trên một datagram.
Hệ quả thứ ba là bài học đo lường của bài này: hai API trông giống hệt nhau vẫn có thể mang ngữ nghĩa khác nhau, và cái khác đó cắn ở chỗ bất ngờ. read() trên stream và trên datagram viết y như nhau, biên dịch như nhau, nhưng một cái kết thúc còn một cái treo. Con số mang theo: Unix socket nhanh ngang pipe (12597 so với 10791 MB/s) vì AF_UNIX đi thẳng trong nhân, không qua mạng; khác biệt thật giữa SOCK_STREAM và SOCK_DGRAM là ranh giới thông điệp và cách báo hết-luồng, không phải tốc độ. Đọc kỹ hợp đồng ngữ nghĩa của thứ mình gọi, đừng chỉ nhìn chữ ký hàm giống nhau mà cho rằng chúng cư xử như nhau.
Thử ba mươi giây
Nếu bạn đang chạy Docker, gõ ls -l /var/run/docker.sock — cái s ở đầu dòng quyền (srw-rw----) cho biết đó là một Unix socket, và mọi lệnh docker bạn gõ đều nói chuyện với daemon qua nó, không qua cổng mạng nào. Muốn thấy tận mắt, chạy strace -e trace=connect docker ps 2>&1 | grep -i unix và bạn sẽ thấy một lệnh connect tới một địa chỉ AF_UNIX chứ không phải AF_INET. Đó là bằng chứng sống rằng những hệ thống nhanh và bận rộn nhất trên máy bạn chọn Unix socket để nói chuyện cục bộ — vì nó nhanh ngang pipe mà linh hoạt hơn nhiều.