Trong một thế giới mà vạn vật đều có thể kết nối với nhau nhờ sự phát triển của công nghệ, từ những máy tính lớn, cho đến chiếc điện thoại di động thông minh, nhu cầu trao đổi dữ liệu giữa các thiết bị số với nhau là không thể tránh khỏi, để tối ưu bài toán trao đổi dữ liệu này, ta sẽ bắt gặp một kỹ thuật kinh điển trong khoa học máy tính, đó là Zero-copy.

Giới thiệu

Nhiều hệ thống có nhu cầu giao tiếp với nhau trong tình huống một bên đọc dữ liệu từ đĩa và ghi lại chính xác nội dung đó về socket phía bên kia. Với cách hiện thực thông thường, sẽ có ít nhất hai lệnh gọi system call để đọc và gửi đi, và dữ liệu sẽ phải di chuyển giữa các không gian địa chỉ user space và kernel space nhiều lần, do dữ liệu đọc từ đĩa không thay đổi trước khi gửi, cho nên ở bước dữ liệu được đưa lên user space cho CPU xử lý là không thật sự cần thiết.

Hướng tiếp cận truyền thống

Xem xét một tình huống đọc dữ liệu từ một file và truyền dữ liệu đó tới một chương tình khác thông qua network (tình huống này mô tả hành vi của nhiều ứng dụng phía server bao gồm Web server cung cấp web tĩnh, FTP server, mail server, v,v). Phần lõi của ứng dụng sẽ có hai lời gọi như sau:

File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);

Listing 1. Hướng tiếp cận sao chép truyền thống

Ý tưởng của Listing 1 đơn giản, nhưng ở bên dưới hai lệnh trên là bốn lần chuyển ngữ cảnh (context switch) giữa user mode và kernel mode, và dữ liệu cũng được sao chép bốn lần trước khi hai lời gọi trên hoàn thành.

Hình 1: Cách sao chép dữ liệu truyền thống

Các bước ở trên bao gồm:

  1. Lệnh read() gây ra context switch từ user mode tới kernel mode. Bên dưới hàm sys_read() (hoặc syscall tương ứng) sẽ đọc dữ liệu từ đĩa. Lần sao chép đầu tiên được thực hiện bởi DMA (direct memory access) engine, nó sẽ đọc nội dung từ điã và lưu trữ chúng trên không gian địa chỉ kernel.
  2. Dữ liệu được trên sẽ được sao chép từ kernel buffer tới user buffer, và lênh read() sẽ trả về. Việc trả về sẽ gây ra một context switch nữa để chuyển từ kernel mode về user mode. Dữ liệu được lưu trên không gian điạ chỉ user.
  3. Lệnh send() được gọi sẽ lần thứ ba gây ra context switch từ user mode tới kernel mode. Lần sao chép thứ ba là sao chép dữ liệu từ user buffer tới một kernel buffer khác liên kết với socket đích.
  4. Lệnh send() trả về tạo ra lần thứ tư context switch. Một cách độc lập và bất đồng bộ, lần sao chép thứ tư sẽ gây ra bởi DMA engine để chuyển dữ liệu từ kernel socket buffer tới protocol engine.

Do sự chênh lệch khá lớn về thời gian xử lý giữa CPU và đĩa, kernel buffer được thêm vào tiến trình đóng vai trò như là một “read ahead cache”. Như vậy mỗi khi có lệnh đọc đĩa thứ hai, mà dữ liệu đã có sẵn trên kernel buffer thì lệnh gọi đọc sẽ trả về và không cần truy xuất đĩa nữa.

Điều không may là hướng tiếp cận đó cũng trở thành một “thắt cổ chai” của hiệu năng hệ thống nếu kích thước của dữ liệu được yêu cầu lớn hơn kích thước của kernel buffer. Dữ liệu sẽ phải được chia nhỏ và sao chép nhiều lần giữa đĩa, kernel buffer, user buffer trước khi hoàn thành, chi phí sao chép và chuyển ngữ cảnh tương đối lớn.

Hướng tiếp cận với kĩ thuật Zero-copy

Nếu chúng ta xem xét cách hiện thực truyền thống, thì sẽ chú ý rằng lần sao chép dữ liệu thứ hai và thứ ba là không thực sự cần thiết bởi vì dữ liệu ở user space cũng không được thay đổi. Thay vì vậy, dữ liệu nên được chuyển trực tiếp từ read buffer đến socket buffer. Thư viện java.nio.channels.FileChannel của ngôn ngữ Java cung cấp hàm transferTo() sẽ làm chính xác việc này.

public void transferTo(long position, long count, WritableByteChannel target);

Listing 2: transferTo() signature

Hàm transferTo() sẽ chuyển dữ liệu từ kênh file đến một kênh ghi cho trước mà không phải thông qua user buffer trung gian. Ở bên dưới hệ điều hành UNIX và nhiều phiên bản Linux, lệnh gọi trên sẽ gọi hàm syscall sendfile().

#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

Listing 3: sendfile() system call

Hành động của file.read() và socket.send() ở listing 1, có thể được thay thế bởi một lời gọi transferTo(), được trình bày ở listing 2.

Hình 2: Sao chép dữ liệu với transferTo()

Các bước xảy ra khi sử dụng hàm transferTo() là:

  1. Sao chép nội dung file từ đĩa đến kernel read buffer bằng DMA engine.
  2. Dữ liệu sẽ được sao chép từ kernel read buffer đến kernel socket buffer.
  3. DMA engine truyền dữ liệu từ kernel socket buffer tới protocol engine.

Đó là một sự cải thiện: chúng ta sẽ giảm số lần context switch từ bốn xuống hai và giảm số lần sao chép dữ liệu từ bốn xuống ba (trong đó chỉ một lần thực hiện bởi CPU). Nhưng nó không hoàn toàn đưa chúng ta đến mục tiêu của zero-copy như tên gọi của chúng là không có bất kì sao chép nào.

Xa hơn, chúng ta có thể lược bỏ lần sao chép kernel buffer còn lại được thực hiện bởi CPU nếu bên dưới giao diện network card có thể hỗ trợ “gather operations”. Ở Linux kernels 2.4 và về sau, socket buffer được thay đổi để phù hợp với yêu cầu trên.

Hình 3: Sao chép dữ liệu khi transferTo() và "gather operations"

Ở phiên bản Linux kernel 2.4 về sau, hàm transferTo() sẽ thực hiện:

  1. Sao chép nội dung file từ đĩa đến kernel buffer bởi DMA engine.
  2. Thông tin về nơi chứa và kích thước của dữ liệu được thêm vào socket buffer. DMA engine chuyển dữ liệu trực tiếp từ kernel buffer đến protocol engine, do đó sẽ lược bỏ lần sao chép được thực hiện bởi CPU cuối cùng.

Các hàm hỗ trợ zero-copy cũng chỉ chuyển được gửi tối đa ~2GB dữ liệu trong một lần gọi (còn tùy thuộc vào hệ thống), nếu chúng ta muốn gửi dữ liệu lớn hơn con số đó thì phải gọi lệnh nhiều lần.

Hình 4: So sánh thời gian của cách thông thường với zero-copy

Khi nào Zero-copy không được sử dụng

Zero-copy không được sử dụng trong trường hợp dữ liệu được yêu cầu cần thay đổi khi chuyển nó tới kênh bên kia, khi đó dữ liệu bắt buộc phải được đưa lên user space để xử lý. Ví dụ chứng chỉ SSL/TLS được dùng trong HTTPS cần mã hóa dữ liệu trước khi chuyển đi thì zero-copy sẽ không giúp ích trong trường hợp này.

Tổng kết

Chúng ta đã hiểu được cách mà zero-copy hoạt động cũng như lợi ích của nó, đa số các ngôn ngữ lập trình cũng đều cung cấp thư viện hỗ trợ kĩ thuật này, do đó nếu ứng dụng có tính chất trao đổi dữ liệu tĩnh giữa các hệ thống, thì zero-copy là kĩ thuật cần được xem xét và áp dụng.

Tham khảo :

https://developer.ibm.com/articles/j-zerocopy/  https://passing-marks.blogspot.com/2017/03/unix-34-advantages-and-disadvantages-of.html https://www.joshbialkowski.com/posts/2018/linux_zero_copy/linux-zero-copy.html