Hai cái vòi: một cái là ống nước chảy liên tục, một cái là băng chuyền đưa từng thùng hàng đóng kín. Bạn hứng nước bằng cách để xô dưới vòi tới khi vòi tắt thì thôi — nhưng làm y hệt với băng chuyền thì bạn đứng chờ mãi, vì băng chuyền không "tắt", nó chỉ hết thùng rồi im lặng chờ thùng sau. Cùng một động tác "hứng tới khi hết", một cái dừng đúng lúc, một cái treo bạn ở đó vĩnh viễn. Đó chính xác là cú vấp của tôi giữa hai kiểu Unix socket. Ở bài về pipe ta thấy pipe truyền dữ liệu 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 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 cùng máy không? Tôi đo cả hai, và vấp một con socket làm treo hẳn chương trì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 con socket làm treo cứng 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ề đến code hằng ngày
Điều đầ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 đó.
Điều 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.
Và cái bẫy sâu nhất — "chữ ký hàm giống hệt nhưng ngữ nghĩa khác, và cái khác cắn ở chỗ bất ngờ" — là một trong những nguồn bug dai dẳng nhất ở mọi tầng, không riêng socket. Chính chuyện "stream gộp byte, không có ranh giới thông điệp" là lý do TCP cũng cần framing (độ dài + nội dung, hay delimiter) — vô số bug "đọc thiếu/đọc dính hai message" trong code mạng Java/Go/Node đến từ việc quên rằng InputStream.read() hay conn.Read() trả về một phần dữ liệu tùy lúc, không phải trọn thông điệp; đó là lý do Netty có LengthFieldBasedFrameDecoder, Go có bufio.Scanner. Ngay cả CSDL: một ResultSet.next() và một cursor server-side trông giống nhau nhưng một cái nạp sẵn cả khối, một cái đi từng dòng — dùng nhầm là hoặc ngốn RAM hoặc chậm bất ngờ. Mẫu số chung: hai API cùng chữ ký (read, next, recv) có thể mang hợp đồng khác nhau về ranh giới và điểm dừng — đọc kỹ hợp đồng ngữ nghĩa, đừng cho rằng gọi giống nhau thì cư xử giống nhau. Gói lại bằng số: Unix socket nhanh ngang pipe (12597 so với 10791 MB/s, khứ hồi 1445 so với 1184 ns) vì AF_UNIX đi thẳng trong nhân, không qua mạng; khác biệt thật giữa SOCK_STREAM (gộp ba lần ghi thành "AAABBCCCC", có EOF) và SOCK_DGRAM (giữ ba gói "AAA"/"BB"/"CCCC", không EOF nên vòng read treo vô tận) là ranh giới thông điệp và cách báo hết-luồng, không phải tốc độ.
Muốn tự thấy thì 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.