天天看點

Linux網絡IO學習筆記前言socket阻塞式網絡IO多路複用網絡IO後記主要參考文獻

前言

本文主要讨論Linux環境下基于TCP的位元組流網絡IO。從socket說起,基于Linux核心源碼,簡析了阻塞式網絡IO模型中,服務端核心協定棧資料接收過程及過程中的主要資料結構,然後通過IO複用模型中資料可讀事件的處理過程,對Linux中的select系統調用與epoll進行了實作原理的介紹。 需要說明的是本文所涉及到的核心處理過程與核心函數等均基于Linux核心版本4.9.93。文中難免會有纰漏甚至錯誤的地方,還請各路大神批評指正。

socket

socket API 起源于1983年發行的4.2BSD作業系統,後來發展成為POSIX(可移植作業系統接口)的一部分。POSIX是由IEEE開發的一系列标椎,由類Unix核心的C語言接口發展而來。Linux作為類Unix作業系統,實作了POSIX的絕大部分API。

Linux網絡IO學習筆記前言socket阻塞式網絡IO多路複用網絡IO後記主要參考文獻

圖1.1、socket API

如圖1.1所示,socket API 主要用作應用層與下層的互動,我們可以使用socket API 編寫使用TCP或UDP的網絡應用程式,并且 socket API 支援繞過傳輸層直接與網絡層進行互動(raw socket)。

Linux socket API:

#include <sys/socket.h>
int socket(int family, int type, int protocol);           
Linux網絡IO學習筆記前言socket阻塞式網絡IO多路複用網絡IO後記主要參考文獻

圖 1.2、socket庫函數參數主要取值組合

圖1.2給出了socket庫函數參數主要取值組合,本文主要讨論 family = AF_INET, tye=SOCK_STREAM, protocol=TCP 的情況。

Linux中 socket 作為一種特殊檔案而存在,在系統啟動時将注冊特殊檔案系統SOCKFS以實作對socket的管理,在 struct socket 變量建立時将通過socket所持有的 struct sock 與 struct file_operations 等變量與系統的協定棧操作相關聯起來。

阻塞式網絡IO

一個簡單的同步阻塞網絡IO Java示例

在Java中,一個簡單的同步阻塞網絡IO示例(省略部分行)如下:

// 服務端
ServerSocket serverSocket = new ServerSocket(6666);
while (true) {
    Socket socket = serverSocket.accept();
    try {
        byte[] bytes = new byte[1024];
        InputStream inputStream = socket.getInputStream();
        while (true) {
            int read = inputStream.read(bytes);
            if (read != -1) {
                System.out.println("====> 來自用戶端:" + new String(bytes, 0, read, "UTF-8"));
            } else {
                System.out.println("====> 用戶端結束通路");
                break;
            }
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}
// 用戶端
Socket socket = new Socket("127.0.0.1", 6666);
OutputStream outputStream = socket.getOutputStream();
outputStream.write("你好".getBytes("UTF-8"));
outputStream.close();
socket.close();           

示例中隻給出了用戶端向服務端發送字元串“你好”的過程:(1)、服務端建立ServerSocket對象,等待用戶端連接配接;(2.1)、用戶端建立Socket對象通過服務端位址主動與服務端建立連接配接并發送資料;(2.2)、服務端收到用戶端連接配接建立Socket對象接收用戶端資料;(3)、用戶端主動關閉連結。

用戶端服務端互動過程淺析

Linux網絡IO學習筆記前言socket阻塞式網絡IO多路複用網絡IO後記主要參考文獻

圖 2.1、用戶端服務端互動過程

圖2.1給出了Java socket 相關操作與 Linux socket API 相應操作的對應關系,與TCP連接配接的建立、資料互動、斷開過程。服務端在建立ServerSocket對象并調用accept()方法之後将一直阻塞至用戶端的連接配接請求到來,用戶端服務端經過TCP三路握手之後建立TCP連結,雙方利用各自的socket檔案描述符進行讀寫操作,完成資料互動。圖中隻給出了用戶端主動關閉連結的情況,服務端在收到來自用戶端的FIN分節之後,确認用戶端資料已經傳輸完畢,read()函數(Linux socket API)将傳回0(Java中SocketInputStream::read将傳回-1)表示資料讀取完畢,随後服務端在确認本側資料發送完畢之後發送FIN分節,關閉連結。

上圖中服務端有可能在三處出現阻塞:(1)、調用accept()方法之後阻塞至用戶端的連接配接請求到來;(2)、調用read()方法之後阻塞至用戶端發送的資料到來;(3)、調用write方法之後,如果TCP發送緩沖區已滿則阻塞至發送緩沖區可用。下文将主要讨論第二種:調用read()方法阻塞的情況。

Linux協定棧資料接收過程淺析

Linux網絡IO學習筆記前言socket阻塞式網絡IO多路複用網絡IO後記主要參考文獻

圖 2.2、read()阻塞至資料接收傳回的過程

圖2.2以及本文接下來所提到的Linux核心處理過程及相應函數均基于Linux核心版本4.9.93。圖中中間部分用不同顔色來标明了CPU的上線文切換情況,但本質上系統調用與傳回将涉及到CPU上下文切換(Linux核心态與使用者态的切換)但是考慮到此過程并沒有發生線程上下文的切換是以在圖中并沒有通過不同顔色進行标記。在圖2.2以及接下來的篇幅中我們将繼續使用線程字樣來描述,但是對于Linux核心來說并沒有線程、程序之分,他們(線程和程序(隻有一個線程的程序))都用唯一的struct task_struct 變量來表述,擁有唯一的pid(同一個程序的線程将擁有同一個tgid)。

圖2.2中給出了服務端調用read()阻塞之後,等待用戶端資料到來,Linux協定棧完成資料解析至read傳回的整體過程,這裡隻讨論資料讀取方先阻塞之後,資料發送方發送資料的情況。圖2.2中:

  • (1.1)、使用者線程調用 read()函數,指明所要讀取的socket檔案描述符,觸發系統調用sys_read(),陷入核心。
  • (1.2)、核心依次調用socket.c:sock_recvmsg()、tcp.c:tcp_recvmsg() 、soct.c:sk_wait_data(),将目前使用者線程加入目前socket的等待隊列(sock.h:struct sock->sk_wq)目前線程讓出CPU,進入睡眠狀态。
  • (2.1)、網卡收到資料發送方所發送的資料,将資料封裝為 skbuff.h: struct sk_buff,并觸發DMA請求。
  • (2.2)、DMA控制器将資料拷貝至記憶體(核心空間),此時 sk_buff 的 head、data 指針指向資料鍊路層資料幀(圖示為以太幀)的頭部開始位置,tail、end 指針指向資料幀的尾部結束位置。
  • (2.3)、資料被拷貝至記憶體之後網卡向CPU發送中斷請求(硬體中斷)。
  • (3)、CPU處理硬體中斷(中斷上半部),此時CPU處在中斷上線文中,中斷處理程式(網卡驅動中實作)将目前網卡裝置加入到CPU:netdevice.h:softnet_data->poll_list中,關閉網卡中斷(在重新打開之前該網卡隻能接收資料不再觸發硬體中斷),觸發軟中斷(NET_RX_SOFTIRQ)。
  • (4)、CPU處理軟中斷(中斷下半部),此時CPU處在中斷上線文或核心線程ksoftirqd上下文中(待處理軟中斷較多時)故圖中間部分用不同顔色進行了标記,軟中斷處理過程中調用dev.c:net_rx_action(softirq_action) 對 poll_list 中的裝置進行輪訓,調用網卡裝置的 poll 函數(網卡驅動中實作),進而調用 dev.c:netif_receive_skb(sk_buff) 将資料推送至協定棧,打開網卡中斷(後續再有新的資料到來可再觸發硬體中斷),将裝置從poll_list中删除。第(3)、(4)步的處理邏輯基于支援NAPI的網卡。
  • (4.1)、dev.c:netif_receive_skb(sk_buff):根據資料幀中的協定類型定位至 ip_input.c:ip_rcv(sk_buff,…),如圖2.2左側所示,此時的sk_buff,head、end 指針指向不變,data指向IP資料報的開始位置、tail 指針指向IP資料報的結束位置。
  • (4.2)、ip_input.c:ip_rcv(sk_buff,…):根據IP資料報中的協定類型定位至 tcp_ipv4.c:tcp_v4_rcv(sk_buff),如圖2.2左側所示,此時的sk_buff,head、end 指針指向不變,data指向TCP分節的開始位置、tail 指針指向TCP分節的結束位置。
  • (4.3)、tcp_ipv4.c:tcp_v4_rcv(sk_buff),如圖2.2左側所示,此時的sk_buff,head、end 指針指向不變,data指向應用層資料的開始位置、tail 指針指向應用層資料的結束位置。
  • (4.3.1)、tcp_ipv4.c:__inet_lookup_skb(sk_buff,…):根據根據四元組(源IP、源端口、目标IP、目标端口)定位 sock.h:struct sock。
  • (4.3.2)、tcp_ipv4.c:tcp_prequeue(sk, skb):将資料加入隊列 tcp.h:struct tcp_sock->ucopy.prequeue。TCP的資料接收會根據不同情況綜合使用四個隊列,此處隻給出了基于圖中事件發生順序的一種情況。
  • (4.3.2.1)、wait.c:__wake_up_sync_key(sk_sleep(sk),…):喚醒等待隊列 sock.h:struct sock->sk_wq 中的使用者線程,至此軟中斷處理完畢。
  • (5)、使用者線程繼續執行tcp.c:tcp_recvmsg() 、 tcp.c:tcp_prequeue_proces()、 tcp_input.c:tcp_rcv_established(sock,sk_buff,…),将應用層資料copy至使用者空間,系統調用傳回(由核心态切換至使用者态)。

圖2.2中使用者線程調用read()函數将一直阻塞至資料發送方資料到來以及自身協定棧處理完成之後才會傳回。

多路複用網絡IO

使用者線程訓示核心等待多個事件中的任何一個發生,并且隻有一個或多個事件發生或經曆一段指定的時間之後才需喚醒使用者線程。

Java中的多路複用

Java中IO多路複用基于選擇器Selector實作,同一Selector可以監聽多個socket的不同僚件,使用Selector的主要步驟如下(省略部分行):

// 初始化 Selector
Selector selector = Selector.open();
// 初始化ServerSocket,綁定監聽端口
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(6666));
serverSocketChannel.configureBlocking(false);
// 向Selector注冊ServerSocket,監聽事件為用戶端連接配接事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
// 循環調用Selector的select()方法,阻塞至所監聽的事件發生或逾時
selector.select(1000L);
// 擷取就緒事件
Set<SelectionKey> selectionKeys = selector.selectedKeys();
// 如果是用戶端連接配接事件則初始化Socket,向Selector注冊Socket,監聽事件為資料可讀事件
if (selectionKey.isAcceptable()) {
    SocketChannel socketChannel = serverSocketChannel.accept();
    socketChannel.configureBlocking(false);
    socketChannel.register(selector, SelectionKey.OP_READ);
}
// 如果是資料可讀事件則根據SelectionKey擷取Socket,進行資料讀取
if (selectionKey.isReadable()) {
    SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
    ...
}
...           

主從Reactor多線程模型

Linux網絡IO學習筆記前言socket阻塞式網絡IO多路複用網絡IO後記主要參考文獻

圖3.1、主從Reactor多線程模型

圖3.1給出了經典的主從Reactor多線程模型,mianReactor負責監聽用戶端連接配接事件(OP_ACCEPT),并将建立的新連接配接(socket)交由subReactor處理,subReactor維護這自己的selector,監聽socket的讀寫事件并進行分發處理,Reactor線程模型的出現讓多路複用網絡IO如虎添翼。

Java中在Linux環境(Linux 2.5.44 以後)下被執行個體化的Selector其實作類為sun.nio.ch.EPollSelectorImpl(MAC下為sun.nio.ch.KQueueSelectorImpl)。Linux中,epoll由系統調用select()、poll()發展而來,下面我們先從select系統調用說起。

select系統調用

Linux select API

#include <sys/select.h>
#include <sys/time.h>
// nfds:檔案描述符最大值+1
// readfds:檔案描述符集合(位圖)所監聽的事件為可讀或新連結,值-結果參數
// writefds:檔案描述符集合(位圖)所監聽的事件為可寫,值-結果參數
// exceptfds:檔案描述符集合(位圖)所監聽的事件為異常,值-結果參數
// timeout:等待時間
// 傳回就緒檔案數目
int select (int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout)           

Linux socket API 使用的主要步驟:(1)、建立socket;(2)設定所監聽的相應事件的檔案描述符集合(fd_set類型的參數);(3)、調用select()函數阻塞至逾時或有所監聽事件發生;(4)、若有所監聽的事件發生,則周遊所監聽的相應事件的檔案描述符集合(fd_set類型的參數),依次檢測所監聽的socket是否有相應事件發生,并做相應處理。

select可讀事件處理

Linux網絡IO學習筆記前言socket阻塞式網絡IO多路複用網絡IO後記主要參考文獻

圖3.2、Linux select系統調用,監聽socket資料可讀事件

圖3.2給出了Linux select系統調用阻塞後出現資料可讀事件後系統調用傳回的過程:

  • (1.1)、使用者線程調用select()函數。
  • (1.2)、觸發系統調用:syscall.h:sys_select() 進而調用 select.c:core_sys_select()。
  • (1.2.1)、将各事件對應的檔案集合從使用者空間拷貝到核心空間。
  • (1.2.2)、調用select.c:int do_select(int n, fd_set_bits *fds, struct timespec64 *end_time)。
  • (1.2.2.1)、select.c:poll_initwait() 初始化回調函數:poll_table pt -> _qproc = select.c:__pollwait(),關系如圖中左側所示。
  • (1.2.2.2)、依次調用每個socket的poll函數(poll_table pt ->_key=所關注的事件),如果有可讀寫或異常poll函數會傳回相應事件(圖中流程為目前無所監聽事件發生的情況)。
  • (1.2.2.2.1)、調用socket.c:sock_poll() 進而調用 tcp.c:tcp_poll() 、poll.h:poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p),執行p->_qproc(filp, wait_address, p),即 select.c:__pollwait(),建立等待隊列節點并将節點加入sock->sk_wq,關系如圖中左側所示。
  • (1.2.2.3)、若所有socket沒有産生調用者所監聽的事件,目前線程讓出CPU,進入睡眠狀态。
  • (2)、出現socket可讀事件(詳細流程可參照圖2.2,此時CPU處在中斷上線文或核心線程ksoftirqd上下文中),周遊等待隊列中的 wait.h:struct wait_queue_t, 執行隊列節點中的wait_queue_func_t 函數,即 select.c:pollwake 根據事件類型喚醒使用者線程,關系如圖中左側所示。
  • (3)、使用者線程繼續執行select.c:core_sys_select() 将産生相應事件的檔案集合由核心空間拷貝至使用者空間,系統調用傳回。

poll()系統調用相對于select()系統調用來說不再有所監聽的最大檔案數目限制(select()系統調用預設最大可監聽1024個檔案,poll()所監聽的最大檔案數目依然受程序所打開的最大檔案數目限制),其實作機制與select()系統調用類似,本文不再詳述。

epoll

Linux epoll API

epoll相較于select、poll有較大的改進,Linux epoll相關API如下:

// 建立epoll傳回檔案描述符,size參數已廢棄
int epoll_create(int size);

// 向epfd上添加/修改/删除所監聽的檔案描述符及相應事件
// epfd: epoll檔案描述符
// op: 操作類型(添加/修改/删除)
// fd: 所要監聽的檔案描述符
// event: 所要監聽的事件
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

// 等待至擷取所監聽的就緒事件集合或逾時傳回
// epfd: epoll檔案描述符
// events: 就緒事件集合,結果參數
// maxevents: events的最大數目
// timeout: 等待逾時時間
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);           

Linux epoll 的主要使用步驟即以上三個函數的調用:建立epoll、設定所要監聽的檔案及事件然後調用epoll_wait等待事件就緒後處理事件。相較于select系統調用,epoll引入特殊檔案eventpoll作為中間層,在核心空間維護了所監聽的檔案事件集合(紅黑樹)與就緒檔案事件連結清單,實作了更高效的IO多路複用。

epoll可讀事件處理

Linux網絡IO學習筆記前言socket阻塞式網絡IO多路複用網絡IO後記主要參考文獻

圖3.3、Linux epoll 監聽socket資料可讀事件

圖3.3給出了利用epoll 監聽socket資料可讀事件直至擷取就緒事件的過程:

  • (1.1)、庫函數調用:int epoll_create(int size):初始化epoll。
  • (1.2)、觸發系統調用:sys_epoll_create(),初始化epoll,傳回檔案描述符epfd。
  • (2.1)、庫函數調用:int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event):向epoll添加所需監聽的socket,等待事件為:有資料可讀(EPOLLIN | EPOLLET)。
  • (2.2)、系統調用:sys_epoll_ctl()。
  • (2.2.1)、調用eventpoll.c:ep_insert():初始化 struct epitem 變量,設定回調函數:poll_table ->_qproc =  eventpoll.c:ep_ptable_queue_proc(),如圖中左側所示。
  • (2.2.1.1)、調用eventpoll.c:ep_item_poll():設定 poll_table->_key 為要監聽的事件,調用所監聽檔案的poll函數(如果目前存在被監聽的事件會傳回相應事件)。
  • (2.2.1.1.1)、調用socket.c:sock_poll()、tcp.c:tcp_poll()、poll.h:poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p),執行p->_qproc(filp, wait_address, p),即 eventpoll.c:ep_ptable_queue_proc()(如圖中左側所示)。
  • (2.2.1.1.1.1)、eventpoll.c:ep_ptable_queue_proc():根據poll_table擷取epitem(container_of宏),初始化 struct epoll_entry 變量,設定 eppoll_entry->wait->private = NULL,eppoll_entry->wait->func=eventpoll.c:ep_poll_callback,eppoll_entry->base=epitem,将epoll_entry->wait加入sock->sk_wq。關聯關系如圖中左側所示。
  • (3.1)、庫函數調用:int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout):等待事件就緒。
  • (3.2)、系統調用:sys_epoll_wait()。
  • (3.2.1)、eventpoll.c:ep_poll():就緒隊列為空,将目前線程加入等待隊列(struct eventpoll->wq),目前線程讓出CPU,進入睡眠狀态。
  • (4)、出現socket可讀事件,系統周遊socket等待隊列(sock->sk_wq)中的 wait.h:struct wait_queue_t, 執行隊列節點中的wait_queue_func_t 函數,即 eventpoll.c:ep_poll_callback(),根據目前wait_queue_t變量擷取(container_of())epitem與eventpoll,将epitem加入eventpoll的事件就緒隊列rdllink,喚醒等待隊列(struct eventpoll->wq)中的等待線程。
  • (5)、繼續eventpoll.c:ep_poll() ,調用eventpoll.c:ep_send_events(),重新調用就緒隊列中socket的poll函數擷取事件,将事件拷貝至使用者空間,系統調用傳回。

epoll觸發模式

epoll支援兩種觸發模式:水準觸發(Level-Triggered)與邊緣觸發(Edge-Triggered)。水準觸發:隻要滿足條件,就觸發一個事件;邊緣觸發:每當狀态變化時,才觸發一個事件。依然拿監聽事件為socket可讀事件舉例,水準觸發模式下,隻要所監聽的socket有資料可讀則當做就緒事件傳回,即便此就緒事件在上一次調用epoll_wait時傳回過,并在上次傳回該事件之後該socket沒有再接收到新的資料;而邊緣觸發模式下隻有在被監聽的socket有新的資料到來時才會被當做就緒事件傳回。epoll預設為水準觸發方式,邊緣觸發EPOLLET為struct epoll_event->events位掩碼取值之一,可通過epoll_ctl函數設定所監聽檔案的觸發模式。

Linux網絡IO學習筆記前言socket阻塞式網絡IO多路複用網絡IO後記主要參考文獻

圖3.4、epoll的水準觸發(LT)與邊緣觸發(ET)

圖3.4中給出了,圖3.3中第(5)步 eventpoll.c:ep_send_events()函數的執行過程。eventpoll.c:ep_send_events_proc()函數中周遊目前epoll的就緒事件連結清單,首先将目前節點從就緒事件清單中删除,然後調用eventpoll.c:ep_item_poll(),其中參數poll_table->_qproc = NULL,是以在調用至sock.h:sock_poll_wait()函數時并不會引發圖3.3中第(2.2.1.1.1)步回調函數eventpoll.c:ep_ptable_queue_proc()的調用。tcp.c:tcp_poll()檢測到資料可讀事件之後傳回該事件,eventpoll.c:ep_send_events_proc()函數中将事件copy至使用者空間後會根據目前socket的觸發模式來判斷是否将目前節點重新加入到epoll就緒事件連結清單。若為水準觸發(LT)模式,則将目前節點重新加入到epoll就緒事件連結清單,故在下次調用epoll_wait函數時會重新檢測是否有資料可讀,若有則繼續傳回該資料可讀事件。若為邊緣觸發(ET)模式,隻有當圖3.3中第(4)步中回調函數eventpoll.c:ep_poll_callback()被重新調用時,目前socket才會有新的可讀事件傳回。

epoll與select的對比

Linux網絡IO學習筆記前言socket阻塞式網絡IO多路複用網絡IO後記主要參考文獻

圖3.4、epoll與select的對比說明

epoll在Nettty中的應用

Netty由Jboss提供,是目前最流行的Java NIO通信架構之一。Netty針對Java NIO類庫做了封裝并提供了豐富的編解碼功能,支援多種應用層主流協定。圖4.1給出了基于epoll的Netty工作架構,Netty提供了靈活的Reactor線程模型的實作,通過不同的構造方法參數和線程組執行個體化個數,能靈活的實作Reactor單線程模型、Reactor多線程模型和主從Reactor多線程模型。Netty自己實作了對Linux epoll API 的調用,相對于JDK的實作,Netty提供了epoll 邊緣觸發模式的支援與更多的socket配置參數設定如:TCP_CORK等。

Linux網絡IO學習筆記前言socket阻塞式網絡IO多路複用網絡IO後記主要參考文獻

圖4.1、基于epoll的Netty工作架構

後記

《UNIX網絡程式設計》一書中給出了類Unix系統下五種可用的IO模型(阻塞式IO、非阻塞式IO、IO複用、信号驅動式IO、異步IO),本文隻涉及到了阻塞式/非阻塞式IO與IO複用。關于異步IO(AIO),目前 Linux 對 POSIX AIO API的實作是由glibc在使用者空間中提供的,有較多限制,基于核心的AIO實作方案目前并不成熟。Java 從1.7開始提供了對AIO的支援,但是在Linux環境下并沒有比基于epoll的IO複用性能更好是以并沒有得到廣泛的應用。

主要參考文獻

《UNIX網絡程式設計卷1:套接字聯網API》

《Linux核心設計與實作》

《深入了解Linux網絡技術内幕》

Epoll的本質(内部實作原理) Linux核心:sk_buff解析 TCP消息的接收 NAPI 技術在 Linux 網絡驅動上的應用和完善