[半原創]nio學習-多路複用

文章前半部分轉載自 : https://mp.weixin.qq.com/s/YdIdoZ_yusVWza1PU7lWaw ,本文在格式上進行優化,方便閱讀 , 非原創
文章後半部分是自己寫的, 原創

概述

爲了講多路複用,當然還是要跟風,採用鞭屍的思路,先講講傳統的網絡 IO 的弊端,用拉踩的方式捧起多路複用 IO 的優勢。
爲了方便理解,以下所有代碼都是僞代碼,知道其表達的意思即可。

阻塞 IO

服務端爲了處理客戶端的連接和請求的數據,寫了如下代碼。


listenfd = socket();   // 打開一個網絡通信端口
bind(listenfd);        // 綁定
listen(listenfd);      // 監聽
while(1) {
  connfd = accept(listenfd);  // 阻塞建立連接
  int n = read(connfd, buf);  // 阻塞讀數據
  doSomeThing(buf);  // 利用讀到的數據做些什麼
  close(connfd);     // 關閉連接,循環等待下一個連接
}

這段代碼會執行得磕磕絆絆,就像這樣。

可以看到,服務端的線程阻塞在了兩個地方,一個是 accept 函數,一個是 read 函數。

如果再把 read 函數的細節展開,我們會發現其阻塞在了兩個階段。

這就是傳統的阻塞 IO。

整體流程如下圖。

1297993-20210826225932244-1864376779.png

所以,如果這個連接的客戶端一直不發數據,那麼服務端線程將會一直阻塞在 read 函數上不返回,也無法接受其他客戶端連接。

這肯定是不行的。

非阻塞 IO

爲了解決上面的問題,其關鍵在於改造這個 read 函數。

有一種聰明的辦法是,每次都創建一個新的進程或線程,去調用 read 函數,並做業務處理。

while(1) {
  connfd = accept(listenfd);  // 阻塞建立連接
  pthread_create(doWork);  // 創建一個新的線程
}
void doWork() {
  int n = read(connfd, buf);  // 阻塞讀數據
  doSomeThing(buf);  // 利用讀到的數據做些什麼
  close(connfd);     // 關閉連接,循環等待下一個連接
}

這樣,當給一個客戶端建立好連接後,就可以立刻等待新的客戶端連接,而不用阻塞在原客戶端的 read 請求上。

不過,這不叫非阻塞 IO,只不過用了多線程的手段使得主線程沒有卡在 read 函數上不往下走罷了。操作系統爲我們提供的 read 函數仍然是阻塞的。

所以真正的非阻塞 IO,不能是通過我們用戶層的小把戲,而是要懇請操作系統爲我們提供一個非阻塞的 read 函數。

這個 read 函數的效果是,如果沒有數據到達時(到達網卡並拷貝到了內核緩衝區),立刻返回一個錯誤值(-1),而不是阻塞地等待。

操作系統提供了這樣的功能,只需要在調用 read 前,將文件描述符設置爲非阻塞即可。

fcntl(connfd, F_SETFL, O_NONBLOCK);
int n = read(connfd, buffer) != SUCCESS);

這樣,就需要用戶線程循環調用 read,直到返回值不爲 -1,再開始處理業務。

這裏我們注意到一個細節。

非阻塞的 read,指的是在數據到達前,即數據還未到達網卡,或者到達網卡但還沒有拷貝到內核緩衝區之前,這個階段是非阻塞的。

當數據已到達內核緩衝區,此時調用 read 函數仍然是阻塞的,需要等待數據從內核緩衝區拷貝到用戶緩衝區,才能返回。

整體流程如下圖

IO 多路複用

爲每個客戶端創建一個線程,服務器端的線程資源很容易被耗光。

當然還有個聰明的辦法,我們可以每 accept 一個客戶端連接後,將這個文件描述符(connfd)放到一個數組裏。

fdlist.add(connfd);

然後弄一個新的線程去不斷遍歷這個數組,調用每一個元素的非阻塞 read 方法。

while(1) {
  for(fd <-- fdlist) {
    if(read(fd) != -1) {
      doSomeThing();
    }
  }
}

這樣,我們就成功用一個線程處理了多個客戶端連接。

你是不是覺得這有些多路複用的意思?

但這和我們用多線程去將阻塞 IO 改造成看起來是非阻塞 IO 一樣,這種遍歷方式也只是我們用戶自己想出的小把戲,每次遍歷遇到 read 返回 -1 時仍然是一次浪費資源的系統調用。

在 while 循環裏做系統調用,就好比你做分佈式項目時在 while 裏做 rpc 請求一樣,是不划算的。

所以,還是得懇請操作系統老大,提供給我們一個有這樣效果的函數,我們將一批文件描述符通過一次系統調用傳給內核,由內核層去遍歷,才能真正解決這個問題。

select

select 是操作系統提供的系統調用函數,通過它,我們可以把一個文件描述符的數組發給操作系統, 讓操作系統去遍歷,確定哪個文件描述符可以讀寫, 然後告訴我們去處理:

select系統調用的函數定義如下。

int select(
    int nfds,
    fd_set *readfds,
    fd_set *writefds,
    fd_set *exceptfds,
    struct timeval *timeout);
// nfds:監控的文件描述符集裏最大文件描述符加1
// readfds:監控有讀數據到達文件描述符集合,傳入傳出參數
// writefds:監控寫數據到達文件描述符集合,傳入傳出參數
// exceptfds:監控異常發生達文件描述符集合, 傳入傳出參數
// timeout:定時阻塞監控時間,3種情況
//  1.NULL,永遠等下去
//  2.設置timeval,等待固定時間
//  3.設置timeval裏時間均爲0,檢查描述字後立即返回,輪詢

服務端代碼,這樣來寫。

首先一個線程不斷接受客戶端連接,並把 socket 文件描述符放到一個 list 裏。

while(1) {
  connfd = accept(listenfd);
  fcntl(connfd, F_SETFL, O_NONBLOCK);
  fdlist.add(connfd);
}

然後,另一個線程不再自己遍歷,而是調用 select,將這批文件描述符 list 交給操作系統去遍歷。

while(1) {
  connfd = accept(listenfd);
  fcntl(connfd, F_SETFL, O_NONBLOCK);
  fdlist.add(connfd);
}

不過,當 select 函數返回後,用戶依然需要遍歷剛剛提交給操作系統的 list。

只不過,操作系統會將準備就緒的文件描述符做上標識,用戶層將不會再有無意義的系統調用開銷。

while(1) {
  nready = select(list);
  // 用戶層依然要遍歷,只不過少了很多無效的系統調用
  for(fd <-- fdlist) {
    if(fd != -1) {
      // 只讀已就緒的文件描述符
      read(fd, buf);
      // 總共只有 nready 個已就緒描述符,不用過多遍歷
      if(--nready == 0) break;
    }
  }
}

正如剛剛的動圖中所描述的,其直觀效果如下。(同一個動圖消耗了你兩次流量,氣不氣?)

可以看出幾個細節:

  1. select 調用需要傳入 fd 數組,需要拷貝一份到內核,高併發場景下這樣的拷貝消耗的資源是驚人的。(可優化爲不復制)
  2. select 在內核層仍然是通過遍歷的方式檢查文件描述符的就緒狀態,是個同步過程,只不過無系統調用切換上下文的開銷。(內核層可優化爲異步事件通知)
  3. select 僅僅返回可讀文件描述符的個數,具體哪個可讀還是要用戶自己遍歷。(可優化爲只返回給用戶就緒的文件描述符,無需用戶做無效的遍歷)
    整個 select 的流程圖如下。

可以看到,這種方式,既做到了一個線程處理多個客戶端連接(文件描述符),又減少了系統調用的開銷(多個文件描述符只有一次 select 的系統調用 + n 次就緒狀態的文件描述符的 read 系統調用)。

poll

poll 也是操作系統提供的系統調用函數。

int poll(struct pollfd *fds, nfds_tnfds, int timeout);

struct pollfd {
  intfd; /*文件描述符*/
  shortevents; /*監控的事件*/
  shortrevents; /*監控事件中滿足條件返回的事件*/
};

它和 select 的主要區別就是,去掉了 select 只能監聽 1024 個文件描述符的限制。

epoll

epoll 是最終的大 boss,它解決了 select 和 poll 的一些問題。

還記得上面說的 select 的三個細節麼?

  1. select 調用需要傳入 fd 數組,需要拷貝一份到內核,高併發場景下這樣的拷貝消耗的資源是驚人的。(可優化爲不復制)

  2. select 在內核層仍然是通過遍歷的方式檢查文件描述符的就緒狀態,是個同步過程,只不過無系統調用切換上下文的開銷。(內核層可優化爲異步事件通知)

  3. select 僅僅返回可讀文件描述符的個數,具體哪個可讀還是要用戶自己遍歷。(可優化爲只返回給用戶就緒的文件描述符,無需用戶做無效的遍歷)
    所以 epoll 主要就是針對這三點進行了改進。

  4. 內核中保存一份文件描述符集合,無需用戶每次都重新傳入,只需告訴內核修改的部分即可。

  5. 內核不再通過輪詢的方式找到就緒的文件描述符,而是通過異步 IO 事件喚醒。

  6. 內核僅會將有 IO 事件的文件描述符返回給用戶,用戶也無需遍歷整個文件描述符集合。
    具體,操作系統提供了這三個函數。

第一步,創建一個 epoll 句柄

int epoll_create(int size);

第二步,向內核添加、修改或刪除要監控的文件描述符。

int epoll_ctl(
  int epfd, int op, int fd, struct epoll_event *event);

第三步,類似發起了 select() 調用

int epoll_wait(
  int epfd, struct epoll_event *events, int max events, int timeout);

使用起來,其內部原理就像如下一般絲滑。

如果你想繼續深入瞭解 epoll 的底層原理,推薦閱讀飛哥的《圖解 | 深入揭祕 epoll 是如何實現 IO 多路複用的!》,從 linux 源碼級別,一行一行非常硬核地解讀 epoll 的實現原理,且配有大量方便理解的圖片,非常適合源碼控的小夥伴閱讀。

後記

大白話總結一下。

一切的開始,都起源於這個 read 函數是操作系統提供的,而且是阻塞的,我們叫它 阻塞 IO。
爲了破這個局,程序員在用戶態通過多線程來防止主線程卡死。
後來操作系統發現這個需求比較大,於是在操作系統層面提供了非阻塞的 read 函數,這樣程序員就可以在一個線程內完成多個文件描述符的讀取,這就是 非阻塞 IO。
但多個文件描述符的讀取就需要遍歷,當高併發場景越來越多時,用戶態遍歷的文件描述符也越來越多,相當於在 while 循環裏進行了越來越多的系統調用。

後來操作系統又發現這個場景需求量較大,於是又在操作系統層面提供了這樣的遍歷文件描述符的機制,這就是 IO 多路複用。

多路複用有三個函數,最開始是 select,然後又發明了 poll 解決了 select 文件描述符的限制,然後又發明了 epoll 解決 select 的三個不足。

所以,IO 模型的演進,其實就是時代的變化,倒逼着操作系統將更多的功能加到自己的內核而已。
如果你建立了這樣的思維,很容易發現網上的一些錯誤。

比如好多文章說,多路複用之所以效率高,是因爲用一個線程就可以監控多個文件描述符。
這顯然是知其然而不知其所以然,多路複用產生的效果,完全可以由用戶態去遍歷文件描述符並調用其非阻塞的 read 函數實現。而多路複用快的原因在於,操作系統提供了這樣的系統調用,使得原來的 while 循環裏多次系統調用,變成了一次系統調用 + 內核層遍歷這些文件描述符。

就好比我們平時寫業務代碼,把原來 while 循環裏調 http 接口進行批量,改成了讓對方提供一個批量添加的 http 接口,然後我們一次 rpc 請求就完成了批量添加。

epoll 驚羣問題

傳統 JAVA 網絡連接寫法

客戶端

public class SocketClient {
	
	public static void main(String[] args) throws InterruptedException {
		try {
			// 和服務器創建連接
			Socket socket = new Socket("localhost",8088);
			
			// 要發送給服務器的信息
			OutputStream os = socket.getOutputStream();
			PrintWriter pw = new PrintWriter(os);
			pw.write("客戶端發送信息");
			pw.flush();
			
			socket.shutdownOutput();
			
			// 從服務器接收的信息
			InputStream is = socket.getInputStream();
			BufferedReader br = new BufferedReader(new InputStreamReader(is));
			String info = null;
			while((info = br.readLine())!=null){
				System.out.println("我是客戶端,服務器返回信息:"+info);
			}
			
			br.close();
			is.close();
			os.close();
			pw.close();
			socket.close();
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
 
}

服務端代碼

public class SocketServer {
	
	public static void main(String[] args) {
		try {
			// 創建服務端socket , 這個地方可以設置多個進程監聽同一個端口!! 
			ServerSocket serverSocket = new ServerSocket(8088);
			
			// 創建客戶端socket
			Socket socket = new Socket();	
			
			//循環監聽等待客戶端的連接
            while(true){
            	// 監聽客戶端 , 如果是多個進程
            	socket = serverSocket.accept();
            	
            	ServerThread thread = new ServerThread(socket);
            	thread.start();
            	
            	InetAddress address=socket.getInetAddress();
                System.out.println("當前客戶端的IP:"+address.getHostAddress());
            }
		} catch (Exception e) {
			// TODO: handle exception
			e.printStackTrace();
		}
	}
 
}
public class ServerThread extends Thread{
	
	private Socket socket = null;
	
	public ServerThread(Socket socket) {
		this.socket = socket;
	}
 
	@Override
	public void run() {
		InputStream is=null;
        InputStreamReader isr=null;
        BufferedReader br=null;
        OutputStream os=null;
        PrintWriter pw=null;
        try {
			is = socket.getInputStream();
			isr = new InputStreamReader(is);
			br = new BufferedReader(isr);
			
			String info = null;
			
			while((info=br.readLine())!=null){
				System.out.println("我是服務器,客戶端說:"+info);
			}
			socket.shutdownInput();
			
			os = socket.getOutputStream();
			pw = new PrintWriter(os);
			pw.write("服務器歡迎你");
			
			pw.flush();
        } catch (Exception e) {
			// TODO: handle exception
		} finally{
			//關閉資源
            try {
                if(pw!=null)
                    pw.close();
                if(os!=null)
                    os.close();
                if(br!=null)
                    br.close();
                if(isr!=null)
                    isr.close();
                if(is!=null)
                    is.close();
                if(socket!=null)
                    socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
		}
	}
 
}

epoll 如何使用

下面是 epoll 的示例 , 重點看一下返回值 , 即 epoll_wait 返回的數據和前面的 select ,poll 這些函數的調用的不同

#include <sys/types.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <netdb.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdlib.h>
#include <errno.h>
#include <sys/wait.h>
#define PROCESS_NUM 10
static int create_and_bind(char *port)
{
    int fd = socket(PF_INET, SOCK_STREAM, 0);
    struct sockaddr_in serveraddr;
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
    serveraddr.sin_port = htons(atoi(port));
    bind(fd, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
    return fd;
}

static int make_socket_non_blocking(int sfd)
{
    int flags, s;

    flags = fcntl(sfd, F_GETFL, 0);
    if (flags == -1)
    {
        perror("fcntl");
        return -1;
    }

    flags |= O_NONBLOCK;
    s = fcntl(sfd, F_SETFL, flags);
    if (s == -1)
    {
        perror("fcntl");
        return -1;
    }

    return 0;
}

#define MAXEVENTS 64


/**
 * @brief 
 * 
 * 
 * 
 * @param argc 
 * @param argv 
 * @return int 
 * 
 * https://www.bookstack.cn/read/linuxapi/docs-epoll_ctl.md 參考這個文檔
 * 
 */
int main(int argc, char *argv[])
{
    int sfd, s;
    int efd;
    struct epoll_event event;
    struct epoll_event *events;

    sfd = create_and_bind("8001");
    if (sfd == -1)
        abort();

    s = make_socket_non_blocking(sfd);
    if (s == -1)
        abort();

    s = listen(sfd, SOMAXCONN);
    if (s == -1)
    {
        perror("listen");
        abort();
    }

    /**
     * @brief 
     * 
     * int epoll_create(int size);
     * 
     * 
     * 
     * 
     */

    efd = epoll_create(MAXEVENTS);
    if (efd == -1)
    {
        perror("epoll_create");
        abort();
    }

    event.data.fd = sfd;
    //event.events = EPOLLIN | EPOLLET;
    event.events = EPOLLIN;

    /**
     * 
     *  int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
     *  op : 操作,(添加,修改或是刪除)
     *  
     *  向 epoll 裏面內核添加、修改或刪除要監控的文件描述符。
     * 
     */

    s = epoll_ctl(efd, EPOLL_CTL_ADD, sfd, &event);
    if (s == -1)
    {
        perror("epoll_ctl");
        abort();
    }

    /* Buffer where events are returned */
    events = calloc(MAXEVENTS, sizeof event);
    int k;
    for (k = 0; k < PROCESS_NUM; k++)
    {
        int pid = fork();
        if (pid == 0)
        {
            /**
             * @brief 
             *  
             *  pid == 0 表示進入到 子進程 
             * 
             */


            printf("在子進程 %d 裏 , 當前 k 值 :  %d \n", getpid(), k);

            /* The event loop */
            while (1)
            {
                int n, i;

                /**
                 * @brief 
                 *  
                 *  epoll_wait 這個函數會阻塞 ,返回的是監聽事件的數量
                 *  其中 , events 表示出參,記錄準備好的fd。該參數爲向量(數組),由調用方分配空間。
                 * 
                 */

                n = epoll_wait(efd, events, MAXEVENTS, -1);
                printf("process %d 從 epoll_wait 中醒來 \n", getpid());
                for (i = 0; i < n; i++)
                {
                    if ((events[i].events & EPOLLERR) || (events[i].events & EPOLLHUP) || (!(events[i].events & EPOLLIN)))
                    {
                        /* An error has occured on this fd, or the socket is not ready for reading (why were we notified then?) */
                        fprintf(stderr, "epoll error\n");
                        close(events[i].data.fd);
                        continue;
                    }
                    else if (sfd == events[i].data.fd)
                    {

                        // 看到了嗎? 上面拿的是 events[i].data.fd ,就緒好的連接(句柄)
                        // 從打印出來的log 可以看到多個 子 process 都會從這個 epoll_wait 中喚醒 ,而最終

                        /* We have a notification on the listening socket, which means one or more incoming connections. */
                        struct sockaddr in_addr;
                        socklen_t in_len;
                        int infd;
                        char hbuf[NI_MAXHOST], sbuf[NI_MAXSERV];

                        in_len = sizeof in_addr;

                        /**
                         * @brief
                         * 
                         *  epoll_wait 了之後再進行 accept , 把準備好的句柄放到 accept 中去 
                         */
                        infd = accept(sfd, &in_addr, &in_len);
                        if (infd == -1)
                        {
                            printf("process %d accept 失敗!\n", getpid());
                            break;
                        }
                        printf("process %d accept 成功!\n", getpid());

                        /* Make the incoming socket non-blocking and add it to the list of fds to monitor. */
                        close(infd);
                    }
                }
            }
        }
    }
    int status;
    wait(&status);
    free(events);
    close(sfd);
    return EXIT_SUCCESS;
}

epoll_wait 返回的數據 epoll_event 的結構如下 , (注意: events事件裏面放着epoll_data_t)

typedef union epoll_data {
    void *ptr;
    int fd;
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;
struct epoll_event {
    __uint32_t events; /* Epoll events */
    epoll_data_t data; /* User data variable */
};

nginx 是如何解決 epoll 驚羣問題

運行上面的例子可以知道 epoll_wait 後 ,會有多個子 process 喚醒,讓我們來看一下nginx 有多少個子 process 是進行 epoll_wait 的.
以前的是這樣的 :

後來發現多個 worker (多個子 process會有驚羣效應), 於是出現了一個鎖來控制驚羣

但是可以而知, 用鎖來控制當併發量大的時候, 可能導致性能不好

於是出現了這種 ,不在侷限於一個 socket ,而是持有多個socket , 然後再分配的時候操作系統做負載均衡, 就可以最高效率地處理請求了.nginx 也是這樣解決的

accept_mutex 鎖 (應用層面)

如果開啓了 accept_mutex 鎖,每個 worker 都會先去搶自旋鎖,只有搶佔成功了,才把 socket 加入到 epoll 中,accept 請求,然後釋放鎖。accept_mutex 鎖也有負載均衡的作用。

accept_mutex 效率低下,特別是在長連接的時候。因爲長連接時,一個進程長時間佔用 accept_mutex 鎖,使得其它進程得不到 accept 的機會(運氣衰,一直搶不到鎖)。因此不建議使用,默認是關閉的。

EPOLLEXCLUSIVE 標識 (操作系統層面)

EPOLLEXCLUSIVE 是 4.5+ 內核新添加的一個 epoll 的標識,Ngnix 在 1.11.3 之後添加了 NGX_EXCLUSIVE_EVENT。
EPOLLEXCLUSIVE 標識會保證一個事件發生時候只有一個線程會被喚醒,以避免多偵聽下的“驚羣”問題。不過任一時候只能有一個工作線程調用 accept,限制了真正並行的吞吐量。

可以看到這是操作系統層面的優化了

SO_REUSEPORT 選項 (操作系統層面)

SO_REUSEPORT 是驚羣最好的解決方法,Ngnix 在 1.9.1 中加入了這個選項,每個 worker 都有自己的 socket,這些 socket 都 bind 同一個端口。當新請求到來時,內核根據四元組信息進行負載均衡,非常高效。(以前共用1個socket )

這算是提升最大的方案, 由一個socket , 到多個 socket

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章