1.1 背景說明:網絡數據傳輸的全過程
在每一次網絡io過程,數據都要經過幾個緩存,再發送出去。如下圖:
以右側為瀏覽器,左側為httpd服務器為例。
- 當httpd服務收到瀏覽器發送的index.html文件的請求時,負責處理請求的httpd子進程/線程總是會先發起系統調用,讓內核將index.html從存儲設備中加載出來。但是加載到的位置是內核空間的緩沖區kernel buffer,而不是直接給進程/線程的內存區。由於是內存設備和存儲設備之間的數據傳輸,沒有CPU的參與,所以這次是DMA操作。
- 當數據准備好后,內核喚醒httpd子進程/線程,讓它使用read()函數把數據復制到它自己的緩沖區,也就是圖中的app buffer。到了app buffer中的數據,已經獨屬於進程/線程,也就可以對它做讀取、修改等等操作。由於這次是使用CPU來復制的,所以會消耗CPU資源。由於這個階段從內核空間切換到用戶空間,所以進行了上下文切換。
- 當數據修改完成(也可能沒做任何操作)后,按我們所想的,需要把它響應給瀏覽器,也就是說要通過TCP連接傳輸出去。但TCP協議棧有自己的緩沖區,要通過它發送數據,必須將數據寫到它的buffer中,對於發送者就是send buffer,對於接受者就是recv buffer。於是,通過write()函數將數據再次從app buffer復制到send buffer。這次也是CPU參與進行的復制,所以會消耗CPU。同樣也會進行上下文切換。
- 非本機數據最終還是會通過網卡傳輸出去的,所以再使用send()函數就可以將send buffer中的數據交給網卡並通過網卡傳輸出去。由於這次是內存和設備之間的數據傳輸,沒有CPU的參與,所以這次也是DMA操作。
- 當瀏覽器所在主機的網卡收到響應數據后(當然,數據是源源不斷傳輸的),將它傳輸到TCP的recv buffer。這次是DMA操作。
- 數據源源不斷地填充到recv buffer中,但是瀏覽器卻不一定會去讀取,而是需要通知瀏覽器進程使用recv()函數將數據從read buffer中取走。這次是CPU操作(圖中忘記標注了)。
需要注意,對於httpd端來說,如果網速很慢,而httpd子進程/線程需要響應出去的數據又足夠大(比send buffer還大),很可能會導致socket buffer填滿的情況,這時write()函數會返回EWOULDBLOCK或EAGAIN,子進程/線程會進入等待狀態。
對於瀏覽器一端來說,如果瀏覽器進程遲遲不將數據從socket buffer(recv buffer)中取走,很可能會導致socket buffer被填滿。
再來說httpd端網絡數據的"經歷"。如下圖:
每次進程/線程需要一段數據時,總是先拷貝到kernel buffer,再拷貝到app buffer,再拷貝到socket buffer,最后再拷貝到網卡上。也就是說,總是會經過4段拷貝經歷。
但想想,正常情況下,數據從存儲設備到kernel buffer是必須的,從socket buffer到NIC也是必須的,但是從kernel buffer到app buffer是必須的嗎?進程一定需要訪問、修改這些數據嗎?不一定,甚至對於web服務來說,如果不是要修改http響應報文,數據完全可以不用經過用戶空間。也就是不用再從kernel buffer拷貝到app buffer,這就是零復制的概念。
零復制的概念是避免將數據在內核空間和用戶空間進行拷貝。主要目的是減少不必要的拷貝,避免讓CPU做大量的數據拷貝任務。
注:上面只是說正常情況下,例如某些硬件可以完成TCP/IP協議棧的工作,數據可以不經過socket buffer,直接在app buffer和硬件之間傳輸數據,RDMA技術就是在此基礎上實現的。
1.2 zero-copy:mmap()
mmap()函數將文件直接映射到用戶程序的內存中,映射成功時返回指向目標區域的指針。這段內存空間可以用作進程間的共享內存空間,內核也可以直接操作這段空間。
在映射文件之后,暫時不會拷貝任何數據到內存中,只有當訪問這段內存時,發現沒有數據,於是產生缺頁訪問,使用DMA操作將數據拷貝到這段空間中。可以直接將這段空間的數據拷貝到socket buffer中。所以也算是零復制技術。如圖:
代碼如下:
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
1.3 zero-copy:sendfile()
man文檔對此函數的描述:
sendfile() copies data between one file descriptor and another. Because this copying is done within the kernel, sendfile() is more efficient than the combination of read(2) and write(2), which would require transferring data to and from user space.
sendfile()函數借助文件描述符來實現數據拷貝:直接將文件描述in_fd的數據拷貝給文件描述符out_fd,其中in_fd是數據提供方,out_fd是數據接收方。文件描述符的操作都是在內核進行的,不會經過用戶空間,所以數據不用拷貝到app buffer,實現了零復制。如下圖
sendfile()的代碼如下:
#include<sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
但是sendfile的in_fd必須指向支持mmap的文件,也就是真實存在的文件,而不能是socket、管道等文件。在Linux 2.6.33之前,還限制out_fd必須是指向socket文件的描述符,所以人們總認為它專門用於進行網絡數據拷貝。但從Linux 2.6.33開始,out_fd可以是任何文件,且如果是一個普通文件,則sendfile()會合理地修改文件的offset。
以nginx開啟了tcp_nopush的sendfile為例,當開啟了tcp_nopush功能后,nginx先在用戶空間構建響應首部,並放進socket send buffer中,然后再向sender buffer中寫入一個待加載文件的標識(例如,聲明我稍后要讀取a.txt文件中的數據發給你),這兩部分先發送給客戶端,然后再加載磁盤文件(sendfile模式加載),每擠滿一次send buffer就發送一次,直到所有數據都發送完。
1.4 zero-copy:splice()
man文檔對此函數的描述:
splice() moves data between two file descriptors without copying between kernel address space and user address space.
It transfers up to len bytes of data from the file descriptor fd_in to the file descriptor fd_out, where one of
thedescriptors must refer to a pipe.
splice()函數可以在兩個文件描述符之間移動數據,且其中一個描述符必須是管道描述符。由於不需要在kernel buffer和app buffer之間拷貝數據,所以實現了零復制。如圖:
注:由於必須有一方是管道描述符,所以上圖中,如果是發送給socket文件描述符,那么是沒有storage-->kernel buffer的DMA操作的。
代碼如下:
#define _GNU_SOURCE /* See feature_test_macros(7) */
#include <fcntl.h>
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);
1.5 zero-copy: tee()
man文檔對此函數的描述:
tee() duplicates up to len bytes of data from the pipe referred to by the file descriptor fd_in to the pipe
referred to by the file descriptor fd_out. It does not consume the data that is duplicated from fd_in;
therefore, that data can be copied by a subsequent splice(2).
tee()函數在兩個管道描述符之間復制數據。由於從in_fd復制給另一個管道out_fd時,不認為數據是來自於in_fd的,所以復制數據后,in_fd仍可使用splice()函數進行數據移動。由於沒有經過用戶空間,所以實現了零復制。如圖:
Linux下的tee程序就是使用tee函數結合splice函數實現的,先將數據通過tee()函數拷貝給管道,再使用splice()函數將數據移動給另一個文件描述符。
代碼如下:
#define _GNU_SOURCE /* See feature_test_macros(7) */
#include <fcntl.h>
ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags);
1.6 寫時復制技術(copy-on-write,COW)
當父進程fork生成子進程時,會復制它的所有內存頁。這至少會導致兩個問題:消耗大量內存;復制操作消耗時間。特別是fork后使用exec加載新程序時,由於會初始化內存空間,所以復制操作幾乎是多余的。
使用copy-on-write技術,使得在fork子進程時不復制內存頁,而是共享內存頁(也就是說,子進程也指向父進程的物理空間),只有在該子進程需要修改某一塊數據,才會將這一塊數據拷貝到自己的app buffer中並進行修改,那么這一塊數據就屬於該子進程的私有數據,可隨意訪問、修改、復制。這在一定程度上實現了零復制,即使復制了一些數據塊,也是在逐漸需要的過程進行復制的。
寫時復制內容太多,簡單概述的話大概就是上面所述內容。