學習筆記——I/O多路複用

前言

我們在學習或者使用nginx、redis或者netty的時候,總是驚訝於它們的高併發性能。但有沒有想過系統是如何在高併發下實現高性能I/O。

什麼是I/O多路複用

I/O多路複用解決的就是併發性效率問題。舉個例子,一個繁忙的WEB服務器每天都要處理上百萬個請求,在網站高峯期的時候必然會同時生成多個請求。處理多個請求最低效的方法是排隊,在處理其中一個請求時,其他所有請求都被阻塞掉,等待前面的請求處理完後再處理剩餘的請求,這樣的效率可想而知。稍好一點的方法是爲每個請求fork()一個進程或者創建一個線程來處理,但是這必然帶來進程(線程)上下文切換的損耗影響效率。
有沒有一種新的方式來處理多個請求,同時實現最大的效率和吞吐量?
有,這就是I/O多路複用。

I/O多路複用本質上就是讓系統內核去輪詢多個Socket請求,只要至少有一個I/O就緒就會通知進程處理,而不用爲了等待某個I/O就緒而阻塞其他請求。
和多線程相比,線程切換需要切換到內核進行線程切換,需要消耗時間和資源。而I/O多路複用不需要切換進程(線程),效率相對較高,特別是在高併發的場景應用如nginx用的就是I/O多路複用。

I/O模型

瞭解I/O多路複用之前,可以先了解一下I/O模型。分別爲:

  • 阻塞性I/O
  • 非阻塞性I/O
  • I/O複用(select和poll)
  • 信號驅動式I/O
  • 異步I/O

關於I/O模型的比較可以參考這篇文章:https://www.cnblogs.com/cainingning/p/9556642.html

現在我們着重關注I/O複用的具體實現select()poll()

select()

讓我們看一個簡單的例子,關於一個回顯的Socket客戶端。
需求是這樣的:實現一個Socket客戶端,客戶端連接到Socket服務端,並且客戶端通過鍵盤輸入文字,傳送到服務端,服務端原封不動把文字回傳到客戶端中,最後客戶端把內容重新顯示到顯示器上。
整個流程大概就是這樣:
在這裏插入圖片描述

Version 1.0 阻塞版本的客戶端

#include	"unp.h"

int
main(int argc, char **argv)
{
	int					sockfd;
	struct sockaddr_in	servaddr;

	if (argc != 2)
		err_quit("usage: tcpcli <IPaddress>");

	sockfd = Socket(AF_INET, SOCK_STREAM, 0);

	bzero(&servaddr, sizeof(servaddr));
	servaddr.sin_family = AF_INET;
	servaddr.sin_port = htons(SERV_PORT);
	Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);

	Connect(sockfd, (SA *) &servaddr, sizeof(servaddr));

	str_cli(stdin, sockfd);		/* do it all */

	exit(0);
}

這段代碼中,重點關注客戶端在Connect()連接到服務端之後,調用str_cli()方法去獲取鍵盤輸入以及把文本傳給服務端

#include	"unp.h"

void
str_cli(FILE *fp, int sockfd)
{
	char	sendline[MAXLINE], recvline[MAXLINE];

	while (Fgets(sendline, MAXLINE, fp) != NULL) {

		Writen(sockfd, sendline, strlen(sendline));

		if (Readline(sockfd, recvline, MAXLINE) == 0)
			err_quit("str_cli: server terminated prematurely");

		Fputs(recvline, stdout);
	}
}


這裏的str_cli()方法完成客戶端處理循環:

  • 從標準輸入讀入一行文本
  • 寫到服務器上
  • 讀回服務器對該行的返回
  • 並把返回寫到標準輸出上

其中第一個和第三個動作都是具有阻塞性的,當執行第一個動作(在鍵盤上鍵入文字)時,進程是阻塞在fgets()上的。同理,當客戶端在接收服務端回寫的文字時,客戶端是無法處理用戶輸入的文字。

我們遇到的問題是,當用戶阻塞在(標準輸入)的時候,如果服務器進程被殺死,此時服務端TCP雖然正確地給客戶端發送一個FIN,但是客戶端卻因爲被阻塞而看不到這個EOF,直到socket超時爲止。

如果有一個方式能夠讓系統只要有I/O就緒就通知進程去處理,而不是被阻塞掉,那該多好啊。
這就是select()存在的意義。

Version 2.0 I/O多路複用的客戶端

先介紹一下select()的方法定義:

#include <sys/select.h>
#include <sys/time.h>

int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *excetset, const struct timeval * timeout);

// 返回:若有就緒描述符則爲其數目,若超時則爲0,若出錯則爲-1

我們可以調用select,告知內核僅在下列情況發生時才返回:

  • 集合{1, 4, 5}中的任何描述符準備好讀;
  • 集合{2, 7}中的任何描述符準備好寫;
  • 集合{1,4}中的任何描述符有異常條件待處理;
  • 已經歷了n秒

這裏只要知道fd_set代表描述符的集合,maxfdp1代表這些集合裏面最大的描述符加1就可以了。
另外還有相關的四個宏:

void FD_ZERO(fd_set *fdset);	// 清除所有的描述符位
void FD_SET(int fd, fd_set *fdset);		// 設置描述符對應的位爲1
void FD_CLR(int fd, fd_set *fdset);		// 設置描述符對應的位爲0
int FD_ISSET(int fd, fd_set *fdset);	// 判斷這個描述符是否已就緒

如果我們要定義一個fd_set的變量,然後打開描述符1、4和5的對應位。

fd_set rset;
FD_ZERO(&rset);		// 初始化fd_set:所有的位置爲0
FD_SET(1, &rset);	// 描述符1的位設置爲1
FD_SET(4, &rset);	// 描述符4的位設置爲1
FD_SET(5, &rset);	// 描述符5的位設置爲1

使用select實現I/O多路複用的str_cli()方法

#include	"unp.h"

void
str_cli(FILE *fp, int sockfd)
{
	int			maxfdp1;
	fd_set		rset;
	char		sendline[MAXLINE], recvline[MAXLINE];

	FD_ZERO(&rset);
	for ( ; ; ) {
		FD_SET(fileno(fp), &rset);
		FD_SET(sockfd, &rset);
		maxfdp1 = max(fileno(fp), sockfd) + 1;
		Select(maxfdp1, &rset, NULL, NULL, NULL);

		if (FD_ISSET(sockfd, &rset)) {	/* socket is readable */
			if (Readline(sockfd, recvline, MAXLINE) == 0)
				err_quit("str_cli: server terminated prematurely");
			Fputs(recvline, stdout);
		}

		if (FD_ISSET(fileno(fp), &rset)) {  /* input is readable */
			if (Fgets(sendline, MAXLINE, fp) == NULL)
				return;		/* all done */
			Writen(sockfd, sendline, strlen(sendline));
		}
	}
}

在這裏可以看到,我們將需要等待的I/O描述符放到fd_set裏面,然後通過select()方法阻塞,當至少有一個I/O就緒時,則繼續往下執行,然後逐個檢查是不是我們需要處理的描述符,如果是則分別對應進行不同的操作。
新的版本是由select方法來驅動的,而舊版本則是由fgets調用來驅動的。從效率和健壯性來說,I/O多路複用比傳統的阻塞I/O更有優勢。

poll()

poll()的功能與select()類似,不同點在於select()使用整型數組fd_set來記錄描述符集合,並且最多隻能關注1024個描述符。而poll()則使用結構數組來記錄描述符,沒有數量限制,並且多了優先級的設置。

#include <poll.h>
int poll(struct pollfd *fdarray, unsigned long nfds, int timeout);

struct pollfd {
	int fd;		// 需要檢查的描述符
	short events;	// 關心的描述符
	short revents;	// 返回的描述符狀態
}

具體的使用方法這裏就不展開討論了。

關於I/O多路複用的更佳實踐還有epoll(),有興趣的同學可以百度一下它們之間的區別,但是核心思想是相通的。

參考資料:《Unix 網絡編程 卷1:套接字聯網 API》 W.Richard Stevens

By ryan.ou

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