Hai cách gửi một mẩu giấy cho đồng nghiệp trong cùng tòa nhà: đưa tay qua bàn (thư nội bộ — Unix socket), hay bỏ vào hệ thống bưu điện, nơi tình cờ có một tuyến "cùng tòa nhà" đi thẳng (TCP loopback). Trực giác bảo thư nội bộ nhanh hơn — nó bỏ qua cả cái bưu điện cồng kềnh. Nhưng nếu điều làm bạn chờ lâu nhất không phải cách gửi, mà là người nhận đang ngủ gật và ai đó phải sang lay dậy — thì cách gửi gần như chẳng còn ý nghĩa. Và nếu cái tuyến bưu điện kia đã được nâng cấp đến mức chạy còn nhanh hơn ở khối lượng lớn, thì "bỏ qua bưu điện" hóa ra chẳng lợi gì. Đó chính là chuyện Unix socket so với TCP loopback.
Ở phần pipe ta đo một cách nối hai tiến trình cùng máy. Nhưng khi hai tiến trình cần giao tiếp kiểu client-server (nhiều kết nối, hai chiều), người ta thường dùng socket — và có hai lựa chọn cùng máy: Unix domain socket (AF_UNIX) hay TCP qua loopback (127.0.0.1). Trực giác — và rất nhiều lời khuyên trên mạng — nói "dùng Unix socket, nó nhanh hơn TCP loopback vì bỏ qua cả stack mạng". Nghe hợp lý. Tôi đo trong container gcc:13 (ARM), và phép đo bác bỏ huyền thoại đó một cách dứt khoát — một lời nhắc rằng lời khuyên "hiển nhiên" cần đo lại trên kernel hiện đại.
Hai cách nối, và lập luận "bỏ stack"
Unix domain socket (AF_UNIX) là một socket sống hoàn toàn trong nhân, nối hai tiến trình cùng máy — không đụng gì tới mạng. TCP loopback dùng chính giao thức TCP/IP nhưng gửi qua giao diện lo (127.0.0.1), không ra card mạng thật. Lập luận cổ điển cho Unix socket: TCP phải làm nhiều việc "mạng" — đóng gói thành segment, tính checksum, quản lý cửa sổ luồng, xác nhận — còn Unix socket bỏ qua tất cả, nên phải nhanh hơn.
Lập luận đó từng đúng. Nhưng có hai điều nó bỏ sót trên kernel hiện đại: (1) loopback TCP được tối ưu rất mạnh — dùng segment lớn (MTU của lo tới 64 KB), và bỏ qua checksum vì dữ liệu không rời máy; (2) với IPC chặn (blocking), chi phí át không phải giao thức mà là đánh thức tiến trình đang ngủ — cái ~9,5 µs ta đã gặp ở pipe và chuyển ngữ cảnh. Đúng chuyện phải sang lay người nhận dậy. Tôi đo để xem hai điều đó lấn át lập luận "bỏ stack" tới đâu.
Đo: độ trễ hòa, throughput TCP còn thắng
Tôi đo cả hai — throughput (đẩy 256 MB) và độ trễ (ping-pong 1 byte):
AF_UNIX : throughput 7,54 GB/s | độ trễ 1 chiều 9.505 ns
TCP loopback : throughput 11,07 GB/s | độ trễ 1 chiều 9.686 ns
-> throughput: UNIX / TCP = 0,68× (TCP nhanh hơn!)
độ trễ: TCP / UNIX = 1,02× (gần như hòa)
Hai kết quả đều ngược huyền thoại. Độ trễ gần như hòa — 9.505 vs 9.686 ns mỗi chiều, chênh 2%. Vì sao? Vì cả hai đều bị chi phí đánh thức tiến trình (~9,5 µs) thống trị: mỗi lần ping-pong, bên nhận đang ngủ chờ dữ liệu và bên gửi phải đánh thức nó — cái giá này bằng nhau bất kể socket loại gì, và nó lớn hơn hàng trăm lần mọi khác biệt giao thức (checksum, đóng gói chỉ tốn vài chục ns). Giao thức gần như không quan trọng khi cái đắt là đánh thức.
Và throughput thì TCP loopback còn thắng — 11,07 vs 7,54 GB/s. Loopback TCP hiện đại gom dữ liệu thành đoạn lớn và bỏ checksum trên lo, chuyển bytes hiệu quả hơn cả AF_UNIX stream trong cấu hình này. "TCP có overhead nên chậm hơn" đơn giản là không còn đúng cho loopback.
(Môi trường đo: container gcc:13 trên host ARM AArch64, throughput đẩy 256 MB, độ trễ ping-pong 1 byte, mỗi số lấy sau warmup. Con số tuyệt đối đổi theo máy/kernel; cái bền vững là: độ trễ bị đánh thức tiến trình thống trị nên loại socket không đổi mấy, và loopback TCP đã tối ưu đủ để không thua.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với niềm tin sách vở và diễn đàn: "Unix socket nhanh hơn hẳn TCP loopback — luôn ưu tiên nó cho IPC cùng máy vì tránh được overhead mạng". Phép đo đập tan cả hai vế: độ trễ hòa (2%), throughput TCP còn nhanh hơn 47%. Lời khuyên hiệu năng "hiển nhiên" — nhất là loại "tầng này bỏ được tầng kia nên nhanh hơn" — cần đo lại trên hệ thống hiện đại, vì kernel đã tối ưu nhiều thứ mà trực giác cũ không tính tới.
Nhưng có một niềm tin ngược, dễ rơi vào sau khi thấy phép đo này, và cũng sai: "vậy Unix socket vô dụng, cứ TCP loopback cho xong". Không — nó có những ưu điểm thật, chỉ là không phải tốc độ: không cần cấp phát cổng (tránh xung đột port, không lộ ra ngoài), phân quyền truy cập theo file (một socket file có chủ và mode như file thường), và khả năng truyền file descriptor giữa tiến trình (SCM_RIGHTS) mà TCP không làm được. Đó là những lý do chính đáng để chọn Unix socket. Sự thật nằm ở chỗ chọn đúng lý do: dùng Unix socket vì tính năng thì đúng, vì "nhanh hơn" thì sai — và cả "Unix socket vô dụng" lẫn "Unix socket nhanh hơn hẳn" đều là kết luận từ việc không đo. Nếu tin huyền thoại mà đổi TCP sang Unix socket kỳ vọng tăng tốc, bạn nhận về không gì cả, thậm chí chậm hơn ở throughput.
Về đến code hằng ngày
Điều đầu tiên: chọn Unix socket vì tính năng, không vì tốc độ. Nếu bạn cần tránh xung đột cổng, kiểm soát truy cập bằng quyền file, hay truyền fd giữa tiến trình — Unix socket đúng. Nhưng nếu bạn đang cân nhắc đổi TCP loopback sang Unix socket chỉ để nhanh hơn, hãy đo trước: rất có thể bạn không được gì. Với IPC cùng máy nói chung, cả hai (và pipe) cùng một bậc hiệu năng.
Điều thứ hai: với IPC chặn, nút thắt là đánh thức tiến trình, không phải giao thức. Nếu độ trễ IPC của bạn quá cao, đổi socket loại này sang loại kia sẽ không cứu — cái ~9,5 µs mỗi lần đánh thức là sàn chung. Muốn thấp hơn thật sự thì phải tránh chặn (busy-poll, shared memory với vòng lặp thăm dò), đổi bằng CPU — hoặc giảm số lần trao đổi (gom thông điệp), đúng bài học batching xuyên suốt.
Chỗ này thú vị là các hệ thật đều mặc định Unix socket cho kết nối cùng máy — nhưng đọc kỹ lý do thì gần như luôn là tính năng, không phải tốc độ. PostgreSQL và MySQL mặc định nối bằng Unix socket khi client cùng máy, và lý do chính là peer authentication (xác thực theo user hệ điều hành qua socket, không cần mật khẩu) cùng việc không chiếm cổng — chứ tài liệu của họ rất dè dặt về "nhanh hơn". Docker phơi API qua /var/run/docker.sock chính vì phân quyền theo file (thuộc nhóm docker), không phải vì throughput. gRPC, Redis (unixsocket), containerd... đều hỗ trợ UDS cho cùng lý do. Và Java mãi tới 16 mới thêm Unix domain socket (JEP 380) — cũng để bằng vai với các công cụ cần peer auth và truyền fd, không phải để đua tốc độ với loopback. Mẫu số chung: UDS là lựa chọn đúng cho IPC cùng máy, nhưng vì nó gọn về cổng và quyền, không vì nó "bỏ được stack nên nhanh".
Muốn tự thấy thì nếu code của bạn nối hai tiến trình cùng máy và bạn từng "tối ưu" bằng cách đổi từ TCP loopback sang Unix socket (hay định làm) — hãy đo cả hai bằng một ping-pong đơn giản trước khi tin nó nhanh hơn. Rất có thể bạn thấy độ trễ gần bằng nhau, vì cả hai cùng trả ~9,5 µs đánh thức tiến trình mỗi lần trao đổi. Rồi hỏi câu quan trọng hơn: bạn chọn Unix socket vì tốc độ (có thể không có thật) hay vì tính năng (không cổng, phân quyền file, truyền fd — những thứ thật sự khác biệt)? Vài phút đo đó cứu bạn khỏi một "tối ưu" phổ biến mà không mang lại gì — và nhắc rằng lời khuyên hiệu năng truyền miệng, nhất là loại "bỏ tầng X nên nhanh hơn", luôn đáng kiểm lại trên chính máy của bạn.