為什么網絡 I/O 會被阻塞?
本文轉載自微信公眾號「yes的練級攻略」,作者是Yes呀。轉載本文請聯系yes的練級攻略公眾號。
你好,我是yes。
最近打算輸出 Netty 相關的文章,但要深入學習 Netty 這個底層通信框架,網絡相關知識點不可或缺。所以我打算先寫一些前置知識點,對齊一下認識,便于之后對 Netty 的理解。
我們應該都知道 socket(套接字),你可以認為我們的通信都要基于這個玩意,而常說的網絡通信又分為 TCP 與 UDP 兩種,下面我會以 TCP 通信為例來闡述下 socket 的通信流程。
不過在此之前,我先來說說什么叫 I/O。
I/O到底是什么?
I/O 其實就是 input 和 output 的縮寫,即輸入/輸出。
那輸入輸出啥呢?
比如我們用鍵盤來敲代碼其實就是輸入,那顯示器顯示圖案就是輸出,這其實就是 I/O。
而我們時常關心的磁盤 I/O 指的是硬盤和內存之間的輸入輸出。
讀取本地文件的時候,要將磁盤的數據拷貝到內存中,修改本地文件的時候,需要把修改后的數據拷貝到磁盤中。
網絡 I/O 指的是網卡與內存之間的輸入輸出。
當網絡上的數據到來時,網卡需要將數據拷貝到內存中。當要發(fā)送數據給網絡上的其他人時,需要將數據從內存拷貝到網卡里。
那為什么都要跟內存交互呢?
我們的指令最終是由 CPU 執(zhí)行的,究其原因是 CPU 與內存交互的速度遠高于 CPU 和這些外部設備直接交互的速度。
因此都是和內存交互,當然假設沒有內存,讓 CPU 直接和外部設備交互,那也算 I/O。
總結下:I/O 就是指內存與外部設備之間的交互(數據拷貝)。
好了,明確什么是 I/O 之后,讓我們來揭一揭 socket 通信內幕~
創(chuàng)建 socket
首先服務端需要先創(chuàng)建一個 socket。在 Linux 中一切都是文件,那么創(chuàng)建的 socket 也是文件,每個文件都有一個整型的文件描述符(fd)來指代這個文件。
- int socket(int domain, int type, int protocol);
- domain:這個參數用于選擇通信的協議族,比如選擇 IPv4 通信,還是 IPv6 通信等等
- type:選擇套接字類型,可選字節(jié)流套接字、數據報套接字等等。
- protocol:指定使用的協議。
這個 protocol 通常可以設為 0 ,因為由前面兩個參數可以推斷出所要使用的協議。
比如socket(AF_INET, SOCK_STREAM, 0);,表明使用 IPv4 ,且使用字節(jié)流套接字,可以判斷使用的協議為 TCP 協議。
這個方法的返回值為 int ,其實就是創(chuàng)建的 socket 的 fd。
bind
現在我們已經創(chuàng)建了一個 socket,但現在還沒有地址指向這個 socket。
眾所周知,服務器應用需要指明 IP 和端口,這樣客戶端才好找上門來要服務,所以此時我們需要指定一個地址和端口來與這個 socket 綁定一下。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
參數里的 sockfd 就是我們創(chuàng)建的 socket 的文件描述符,執(zhí)行了 bind 參數之后我們的 socket 距離可以被訪問又更近了一步。
listen
執(zhí)行了 socket、bind 之后,此時的 socket 還處于 closed 的狀態(tài),也就是不對外監(jiān)聽的,然后我們需要調用 listen 方法,讓 socket 進入被動監(jiān)聽狀態(tài),這樣的 socket 才能夠監(jiān)聽到客戶端的連接請求。
int listen(int sockfd, int backlog);
傳入創(chuàng)建的 socket 的 fd,并且指明一下 backlog 的大小。
這個 backlog 我查閱資料的時候,看到了三種解釋:
- socket 有一個隊列,同時存放已完成的連接和半連接,backlog為這個隊列的大小。
- socket 有兩個隊列,分別為已完成的連接隊列和半連接隊列,backlog為這個兩個隊列的大小之和。
- socket 有兩個隊列,分別為已完成的連接隊列和半連接隊列,backlog僅為已完成的連接隊列大小。
解釋下什么叫半連接
我們都知道 TCP 建立連接需要三次握手,當接收方收到請求方的建連請求后會返回 ack,此時這個連接在接收方就處于半連接狀態(tài),當接收方再收到請求方的 ack 時,這個連接就處于已完成狀態(tài):
所以上面討論的就是這兩種狀態(tài)的連接的存放問題。
我查閱資料看到,基于 BSD 派生的系統的實現是使用的一個隊列來同時存放這兩種狀態(tài)的連接, backlog 參數即為這個隊列的大小。
而 Linux 則使用兩個隊列分別存儲已完成連接和半連接,且 backlog 僅為已完成連接的隊列大小
accept
現在我們已經初始化好監(jiān)聽套接字了,此時會有客戶端連上來,然后我們需要處理這些已經完成建連的連接。
從上面的分析我們可以得知,三次握手完成后的連接會被加入到已完成連接隊列中去。
這時候,我們就需要從已完成連接隊列中拿到連接進行處理,這個拿取動作就由 accpet 來完成。
- int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
這個方法返回的 int 值就是拿到的已完成連接的 socket 的文件描述符,之后操作這個 socket 就可以進行通信了。
如果已完成連接隊列沒有連接可以取,那么調用 accept 的線程會阻塞等待。
至此服務端的通信流程暫告一段落,我們再看看客戶端的操作。
connect
客戶端也需要創(chuàng)建一個 socket,也就是調用 socket(),這里就不贅述了,我們直接開始建連操作。
客戶端需要與服務端建立連接,在 TCP 協議下開始經典的三次握手操作,再看一下上面畫的圖:
客戶端創(chuàng)建完 socket 并調用 connect 之后,連接就處于 SYN_SEND 狀態(tài),當收到服務端的 SYN+ACK 之后,連接就變?yōu)? ESTABLISHED 狀態(tài),此時就代表三次握手完畢。
- int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
調用connect需要指定遠程的地址和端口進行建連,三次握手完畢之后就可以開始通信了。
客戶端這邊不需要調用 bind 操作,默認會選擇源 IP 和隨機端口。
用一幅圖來小結一下建連的操作:
可以看到這里的兩個阻塞點:
- connect:需要阻塞等待三次握手的完成。
- accept:需要等待可用的已完成的連接,如果已完成連接隊列為空,則被阻塞。
read、write
連接建立成功之后,就能開始發(fā)送和接收消息了,我們來看一下
read 為讀數據,從服務端來看就是等待客戶端的請求,如果客戶端不發(fā)請求,那么調用 read 會處于阻塞等待狀態(tài),沒有數據可以讀,這個應該很好理解。
write 為寫數據,一般而言服務端接受客戶端的請求之后,會進行一些邏輯處理,然后再把結果返回給客戶端,這個寫入也可能會被阻塞。
這里可能有人就會問 read 讀不到數據阻塞等待可以理解,write 為什么還要阻塞,有數據不就直接發(fā)了嗎?
因為我們用的是 TCP 協議,TCP 協議需要保證數據可靠地、有序地傳輸,并且給予端與端之間的流量控制。
所以說發(fā)送不是直接發(fā)出去,它有個發(fā)送緩沖區(qū),我們需要把數據先拷貝到 TCP 的發(fā)送緩沖區(qū),由 TCP 自行控制發(fā)送的時間和邏輯,有可能還有重傳什么的。
如果我們發(fā)的過快,導致接收方處理不過來,那么接收方就會通過 TCP 協議告知:別發(fā)了!忙不過來了。發(fā)送緩存區(qū)是有大小限制的,由于無法發(fā)送,還不斷調用 write 那么緩存區(qū)就滿了,滿了就不然你 write 了,所以 write 也會發(fā)生阻塞。
綜上,read 和 write 都會發(fā)生阻塞。
最后
為什么網絡 I/O 會被阻塞?
因為建連和通信涉及到的 accept、connect、read、write 這幾個方法都可能會發(fā)生阻塞。
阻塞會占用當前執(zhí)行的線程,使之不能進行其他操作,并且頻繁阻塞喚醒切換上下文也會導致性能的下降。
由于阻塞的緣故,起初的解決的方案就是建立多個線程,但是隨著互聯網的發(fā)展,用戶激增,連接數也隨著激增,需要建立的線程數也隨著一起增加,到后來就產生了 C10K 問題。
服務端頂不住了呀,咋辦?
優(yōu)化唄!
所以后來就弄了個非阻塞套接字,然后 I/O多路復用、信號驅動I/O、異步I/O。
下篇我們就來好好盤盤,這幾種 I/O 模型!