IO多路復用是什么?如何設計一個高性能服務器?

筆記同步到了我的個人網站上,b站好像不支持markdown語法,所以為了一個更好的體驗,歡迎前來圍觀(?′?`?):http://www.ghost-him.com/posts/5203630b/
文中代碼采用 c 風格,linux 下的系統(tǒng)調用的名稱。只采用偽代碼的形式編寫,無法運行。
<!-- more -->
## 阻塞 IO 形式
最簡單的服務器形式
偽代碼形式:
```cpp
server_fd = socket(); // 創(chuàng)建一個socket
bind(server_fd, "0.0.0.0", 8080); // 將當前的socket綁定到一個指定的地址和端口
listen(server_fd) // 監(jiān)聽連接請求
while (1) {
client_fd = accept(server_fd); // 接受連接請求
if (read(client_fd, buff)) { // 從client_fd中讀取數據,并將數據存放到buff中
handler(buff) // 處理當前buff中的數據
} else {
close(client_fd) // 如果已經讀完了,則關閉連接
}
}
```
特點:一次只可以處理一個連接,處理完以后才可以接受下一個連接的請求。
原因:這里的 `accept` 函數和 `read` 函數都是阻塞的。
如果要支持多個客戶端的連接請求,那么可以對代碼做一些改進:
```cpp
server_fd = socket(); // 創(chuàng)建一個socket
bind(server_fd, "0.0.0.0", 8080); // 將當前的socket綁定到一個指定的地址和端口
listen(server_fd) // 監(jiān)聽連接請求
while (1) {
client_fd = accept(server_fd); // 接受連接請求
fds.add(client_fd); // 將當前的新連接的fd添加到fds的連接數組中
for (fd : fds) { // 遍歷當前fds中的所有的fd
if (read(fd, buff)) { // 從client_fd中讀取數據,并將數據存放到buff中
handler(buff) // 處理當前buff中的數據
} else {
close(fd) // 如果已經讀完了,則關閉連接
}
}
}
```
代碼存在的問題:
1. 如果在等待新的連接時,無法處理已經連接上的請求。
2. 同理,如果在等待已經連接上的 fd 傳輸數據時,無法連接新的請求。
3. 如果在遍歷 fds 時,如果其中的一個 fd 一直沒有傳輸數據過來,那么整個程序會卡死(一直阻塞在 read 函數)。
產生問題的原因:`read` 函數和 `accept` 函數相互影響導致的。
解決辦法:引入多線程。
## 阻塞 IO+多線程
```cpp
server_fd = socket(); // 創(chuàng)建一個socket
bind(server_fd, "0.0.0.0", 8080); // 將當前的socket綁定到一個指定的地址和端口
listen(server_fd) // 監(jiān)聽連接請求
while (1) {
client_fd = accept(server_fd); // 接受連接請求
pthread_create(client_fd){ // 創(chuàng)建一個新的線程
while(1) {
if (read(client_fd, buff)) { // 從client_fd中讀取數據,并將數據存放到buff中
handler(buff) // 處理當前buff中的數據
} else {
close(client_fd) // 如果已經讀完了,則關閉連接
}
}
}
}
```
特點:
1. 可以實現(xiàn)一個可用的多線程 tcp 服務器,同時支持處理多個客戶端的連接請求。
2. 一個線程處理一個連接
缺點:
1. 無法處理大量連接
原因:每個線程都會占用一定的資源(時,空),所以不但可以創(chuàng)建的線程數是有限的,而且上下文切換的也會占用大量的時間,會影響處理的效率。
解決方法:使用線程池來代替大量的線程
```cpp
server_fd = socket(); // 創(chuàng)建一個socket
bind(server_fd, "0.0.0.0", 8080); // 將當前的socket綁定到一個指定的地址和端口
listen(server_fd) // 監(jiān)聽連接請求
thread_pool_create(num) // 創(chuàng)建指定數量的線程
while (1) {
client_fd = accept(server_fd); // 接受連接請求
thread_pool_get(client_fd) {?// 從線程池中獲取一個線程
while(1) {
if (read(client_fd, buff)) { // 從client_fd中讀取數據,并將數據存放到buff中
handler(buff) // 處理當前buff中的數據
} else {
close(client_fd) // 如果已經讀完了,則關閉連接
break;?// 跳出循環(huán),將線程放回線程池?
}
}
}
}
```
缺點:
1. 獲取線程 `thread_pool_get` 是一個阻塞的函數,所以會影響服務器的處理能力。
2. 在高并發(fā)的環(huán)境下,主線程會由于線程池中的線程的數量受到限制,從而無法處理新的請求(直到有舊的連接關閉,線程釋放)
原因:每個連接都要一個線程處理,而線程池中的線程是有限的,所以線程池的大小就決定了同時在線連接數的數量。
解決辦法:
1. 部署更多的服務器
## 非阻塞 IO
對于一個網絡 IO,共有兩個系統(tǒng)對象,一個是應用進程,一個是系統(tǒng)內核。當一個 read 函數發(fā)生時,會有兩個階段:
1. 等待數據準備
2. 將數據從內核拷貝到用戶空間
在阻塞 IO 模型中,只有當這兩個階段都完成了以后都會返回。
所以這里就是一個可以優(yōu)化的地方。
在非阻塞 IO 模型中,當應用線程發(fā)出 read 系統(tǒng)調用的時候,如果內核中的數據還沒有準備好,他并不會去阻塞應用的線程,而是返回一個錯誤。對于應用線程來說,發(fā)出一個 read 系統(tǒng)調用以后不需要等待,就可以得到一個結果。如果這個結果是一個錯誤,那么就說明當前還沒有準備好,于是,可以再次發(fā)送一個 read 操作。當數據已經準備好了,并且應用線程發(fā)送了一個 read 系統(tǒng)調用的時候,內核會將數據拷貝到應用進程,拷貝完以后再返回成功。
因此,對于阻塞模型與非阻塞模型來說,不同的地方在于第一階段,第二階段下,兩個模型都是一樣的。
```cpp
server_fd = socket(); // 創(chuàng)建一個socket
bind(server_fd, "0.0.0.0", 8080); // 將當前的socket綁定到一個指定的地址和端口
listen(server_fd) // 監(jiān)聽連接請求
set_non_block(server_fd) // 設置成非阻塞
while (1) {
client_fd = accept(server_fd); // 接受連接請求
if (client_fd > 0) {
set_non_block(client_fd); // 設置非阻塞模式
fds.add(client_fd); // 新的fd加入到fds中
}
for (fd : fds) { // 遍歷當前fds中的所有的fd
n = read(fd, buff); // 非阻塞的讀取數據
if (n == -1) {
continue; // 無數據可讀
} else if (n == 0) { // 連接關閉
close(fd); // 斷開連接
} else {
handler(buff) // 讀到數據邏輯處理
}
}
}
```
缺點:`while` 循環(huán)中會不斷的向系統(tǒng)詢問,系統(tǒng)的開銷很大,同時會占用大量的 cpu 資源。
## IO 多路復用
目的:避免應用線程循環(huán)檢查發(fā)起系統(tǒng)調用的開銷
原理:將需要監(jiān)聽的文件描述符,通過一個系統(tǒng) (select, poll, epoll 等)一直傳遞到內核中,由內核來監(jiān)視這些文件描述符。當其中的任意一個文件描述符發(fā)生了 I/O 事件(讀,寫,連接,關閉等),內核就會通知應用程序進行處理。
多路是指需要處理的多個連接的 I/O 事件,復用是指復用一個或少量的線程資源。I/O 多路復用就是用一個或者少量的線程資源去處理多個連接的 I/O 事件。
使用 select 函數來舉例:
```cpp
server_fd = socket();
bind(server_fd, "0,0,0,0", 8080);
listen(server_Fd);
readfds; // 待監(jiān)聽的集合
client_fds; // 連接描述符數組
while (1) {
// 清空集合
FD_ZERO(&readfds);
// 添加server_Fd 到集合中
FD_SET(server_Fd. &readfds);
// 遍歷連接fd集合
for (fd : client_fds) {
//將有效的fd添加到集合中
if (fd) {
FD_SET(fd, &readfds);
}
}
// 阻塞等待fd上的IO事件
select(fd_num, &readfds, NULL, NULL, NULL);
// 如果server_fd有事件,則有新的連接
if (FD_ISSET(server_fd, &readfds)) {
client_fd = accept(server_fd);
// 新的fd加入到數組中
client_fds.add(client_fd);
}
for (fd : client_fds) {
// 如果有IO事件
if (FD_ISSET(fd, &readfds)) {
if (read(fd, buff)) {
handler(buff);
} else {
// 連接關閉?
close(fd);
// 從集合中移除
client_fds.remove(fd);
}
}
}
}
```
優(yōu)點:避免了主動的輪詢,減少了 cpu 的占用。
缺點:
1. 每次在調用 select 函數都需要重新初始化待監(jiān)聽描述符集合
2. 每次都要將描述符集合拷貝到內核中
3. Select 返回后需要遍歷所有文件描述符,依次檢查是否就緒。(即使就一個準備好,也要遍歷全部)
4. 最多只可以監(jiān)聽 1024 個文件描述符
`poll` 對第 1 點和第 4 點做了優(yōu)化。`epoll` 對所有的缺點都進行了優(yōu)化。`epoll` 使用內核空間和用戶空間共享的內存區(qū)來傳遞文件描述符,避免了從用戶態(tài)向內核態(tài)拷貝的開銷。同時,不需要遍歷全部文件描述符,因為它只將發(fā)生變動的文件描述符返回。
**注:看評論區(qū)說 epoll 會將數據從內核拷貝到用戶空間,并不是共享內存實現(xiàn)的**