Unix/Linux中的read和write函數
檔案描述符
對于核心而言,所有打開的檔案都通過檔案描述符引用。檔案描述符是一個非負整數。當打開一個現有檔案或建立一個新檔案時,核心向程序傳回一個檔案描述符。當讀或寫一個檔案時,使用open或create傳回的檔案描述符表示該檔案,将其作為參數傳給read或write函數。
write函數
write函數定義如下:
#include
ssize_t write(int filedes, void *buf, size_t nbytes);
// 傳回:若成功則傳回寫入的位元組數,若出錯則傳回-1
// filedes:檔案描述符
// buf:待寫入資料緩存區
// nbytes:要寫入的位元組數
同樣,為了保證寫入資料的完整性,在《UNIX網絡程式設計 卷1》中,作者将該函數進行了封裝,具體程式如下:
View Code
1 ssize_t /* Write "n" bytes to a descriptor. */
2 writen(int fd, const void *vptr, size_t n)
3 {
4 size_t nleft;
5 ssize_t nwritten;
6 const char *ptr;
7
8 ptr = vptr;
9 nleft = n;
10 while (nleft > 0) {
11 if ( (nwritten = write(fd, ptr, nleft)) <= 0) {
12 if (nwritten < 0 && errno == EINTR)
13 nwritten = 0; /* and call write() again */
14 else
15 return(-1); /* error */
16 }
17
18 nleft -= nwritten;
19 ptr += nwritten;
20 }
21 return(n);
22 }
23 /* end writen */
24
25 void
26 Writen(int fd, void *ptr, size_t nbytes)
27 {
28 if (writen(fd, ptr, nbytes) != nbytes)
29 err_sys("writen error");
30 }
read函數
read函數定義如下:
#include
ssize_t read(int filedes, void *buf, size_t nbytes);
// 傳回:若成功則傳回讀到的位元組數,若已到檔案末尾則傳回0,若出錯則傳回-1
// filedes:檔案描述符
// buf:讀取資料緩存區
// nbytes:要讀取的位元組數
有幾種情況可使實際讀到的位元組數少于要求讀的位元組數:
1)讀普通檔案時,在讀到要求位元組數之前就已經達到了檔案末端。例如,若在到達檔案末端之前還有30個位元組,而要求讀100個位元組,則read傳回30,下一次再調用read時,它将傳回0(檔案末端)。
2)當從終端裝置讀時,通常一次最多讀一行。
3)當從網絡讀時,網絡中的緩存機構可能造成傳回值小于所要求讀的字結束。
4)當從管道或FIFO讀時,如若管道包含的位元組少于所需的數量,那麼read将隻傳回實際可用的位元組數。
5)當從某些面向記錄的裝置(例如錄音帶)讀時,一次最多傳回一個記錄。
6)當某一個信号造成中斷,而已經讀取了部分資料。
在《UNIX網絡程式設計 卷1》中,作者将該函數進行了封裝,以確定資料讀取的完整,具體程式如下:
View Code
1 ssize_t /* Read "n" bytes from a descriptor. */
2 readn(int fd, void *vptr, size_t n)
3 {
4 size_t nleft;
5 ssize_t nread;
6 char *ptr;
7
8 ptr = vptr;
9 nleft = n;
10 while (nleft > 0) {
11 if ( (nread = read(fd, ptr, nleft)) < 0) {
12 if (errno == EINTR)
13 nread = 0; /* and call read() again */
14 else
15 return(-1);
16 } else if (nread == 0)
17 break; /* EOF */
18
19 nleft -= nread;
20 ptr += nread;
21 }
22 return(n - nleft); /* return >= 0 */
23 }
24 /* end readn */
25
26 ssize_t
27 Readn(int fd, void *ptr, size_t nbytes)
28 {
29 ssize_t n;
30
31 if ( (n = readn(fd, ptr, nbytes)) < 0)
32 err_sys("readn error");
33 return(n);
34 }
本文下半部分摘自博文淺談TCP/IP網絡程式設計中socket的行為。
read/write的語義:為什麼會阻塞?
先從write說起:
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
首先,write成功傳回,隻是buf中的資料被複制到了kernel中的TCP發送緩沖區。至于資料什麼時候被發往網絡,什麼時候被對方主機接收,什麼時候被對方程序讀取,系統調用層面不會給予任何保證和通知。
write在什麼情況下會阻塞?當kernel的該socket的發送緩沖區已滿時。對于每個socket,擁有自己的send buffer和receive buffer。從Linux 2.6開始,兩個緩沖區大小都由系統來自動調節(autotuning),但一般在default和max之間浮動。
擷取socket的發送/接受緩沖區的大小:(後面的值是在Linux 2.6.38 x86_64上測試的結果)
sysctl net.core.wmem_default #126976
sysctl net.core.wmem_max #131071
已經發送到網絡的資料依然需要暫存在send buffer中,隻有收到對方的ack後,kernel才從buffer中清除這一部分資料,為後續發送資料騰出空間。接收端将收到的資料暫存在receive buffer中,自動進行确認。但如果socket所在的程序不及時将資料從receive buffer中取出,最終導緻receive buffer填滿,由于TCP的滑動視窗和擁塞控制,接收端會阻止發送端向其發送資料。這些控制皆發生在TCP/IP棧中,對應用程式是透明的,應用程式繼續發送資料,最終導緻send buffer填滿,write調用阻塞。
一般來說,由于接收端程序從socket讀資料的速度跟不上發送端程序向socket寫資料的速度,最終導緻發送端write調用阻塞。
而read調用的行為相對容易了解,從socket的receive buffer中拷貝資料到應用程式的buffer中。read調用阻塞,通常是發送端的資料沒有到達。
blocking(預設)和nonblock模式下read/write行為的差別
将socket fd設定為nonblock(非阻塞)是在伺服器程式設計中常見的做法,采用blocking IO并為每一個client建立一個線程的模式開銷巨大且可擴充性不佳(帶來大量的切換開銷),更為通用的做法是采用線程池+Nonblock I/O+Multiplexing(select/poll,以及Linux上特有的epoll)。
複制代碼
1 // 設定一個檔案描述符為nonblock
2 int set_nonblocking(int fd)
3 {
4 int flags;
5 if ((flags = fcntl(fd, F_GETFL, 0)) == -1)
6 flags = 0;
7 return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
8 }
複制代碼
幾個重要的結論:
1. read總是在接收緩沖區有資料時立即傳回,而不是等到給定的read buffer填滿時傳回。
隻有當receive buffer為空時,blocking模式才會等待,而nonblock模式下會立即傳回-1(errno = EAGAIN或EWOULDBLOCK)
注:阻塞模式下,當對方socket關閉時,read會傳回0。
2. blocking的write隻有在緩沖區足以放下整個buffer時才傳回(與blocking read并不相同)
nonblock write則是傳回能夠放下的位元組數,之後調用則傳回-1(errno = EAGAIN或EWOULDBLOCK)
對于blocking的write有個特例:當write正阻塞等待時對面關閉了socket,則write則會立即将剩餘緩沖區填滿并傳回所寫的位元組數,再次調用則write失敗(connection reset by peer),這正是下個小節要提到的:
read/write對連接配接異常的回報行為
對應用程式來說,與另一程序的TCP通信其實是完全異步的過程:
1. 我并不知道對面什麼時候、能否收到我的資料
2. 我不知道什麼時候能夠收到對面的資料
3. 我不知道什麼時候通信結束(主動退出或是異常退出、機器故障、網絡故障等等)
對于1和2,采用write() -> read() -> write() -> read() ->…的序列,通過blocking read或者nonblock read+輪詢的方式,應用程式基于可以保證正确的處理流程。
對于3,kernel将這些事件的“通知”通過read/write的結果傳回給應用層。
假設A機器上的一個程序a正在和B機器上的程序b通信:某一時刻a正阻塞在socket的read調用上(或者在nonblock下輪詢socket)
當b程序終止時,無論應用程式是否顯式關閉了socket(OS會負責在程序結束時關閉所有的檔案描述符,對于socket,則會發送一個FIN包到對面)。
”同步通知“:程序a對已經收到FIN的socket調用read,如果已經讀完了receive buffer的剩餘位元組,則會傳回EOF:0
”異步通知“:如果程序a正阻塞在read調用上(前面已經提到,此時receive buffer一定為空,因為read在receive buffer有内容時就會傳回),則read調用立即傳回EOF,程序a被喚醒。
socket在收到FIN後,雖然調用read會傳回EOF,但程序a依然可以其調用write,因為根據TCP協定,收到對方的FIN包隻意味着對方不會再發送任何消息。 在一個雙方正常關閉的流程中,收到FIN包的一端将剩餘資料發送給對面(通過一次或多次write),然後關閉socket。
但是事情遠遠沒有想象中簡單。優雅地(gracefully)關閉一個TCP連接配接,不僅僅需要雙方的應用程式遵守約定,中間還不能出任何差錯。
假如b程序是異常終止的,發送FIN包是OS代勞的,b程序已經不複存在,當機器再次收到該socket的消息時,會回應RST(因為擁有該socket的程序已經終止)。a程序對收到RST的socket調用write時,作業系統會給a程序發送SIGPIPE,預設處理動作是終止程序,知道你的程序為什麼毫無征兆地死亡了吧:)
from 《Unix Network programming, vol1》 3rd Edition:
“It is okay to write to a socket that has received a FIN, but it is an error to write to a socket that has received an RST.”
通過以上的叙述,核心通過socket的read/write将雙方的連接配接異常通知到應用層,雖然很不直覺,似乎也夠用。
這裡說一句題外話:
不知道有沒有同學會和我有一樣的感慨:在寫TCP/IP通信時,似乎沒怎麼考慮連接配接的終止或錯誤,隻是在read/write錯誤傳回時關閉socket,程式似乎也能正常運作,但某些情況下總是會出奇怪的問題。想完美處理各種錯誤,卻發現怎麼也做不對。
原因之一是:socket(或者說TCP/IP棧本身)對錯誤的回報能力是有限的。
考慮這樣的錯誤情況:
不同于b程序退出(此時OS會負責為所有打開的socket發送FIN包),當B機器的OS崩潰(注意不同于人為關機,因為關機時所有程序的退出動作依然能夠得到保證)/主機斷電/網絡不可達時,a程序根本不會收到FIN包作為連接配接終止的提示。
如果a程序阻塞在read上,那麼結果隻能是永遠的等待。
如果a程序先write然後阻塞在read,由于收不到B機器TCP/IP棧的ack,TCP會持續重傳12次(時間跨度大約為9分鐘),然後在阻塞的read調用上傳回錯誤:ETIMEDOUT/EHOSTUNREACH/ENETUNREACH
假如B機器恰好在某個時候恢複和A機器的通路,并收到a某個重傳的pack,因為不能識别是以會傳回一個RST,此時a程序上阻塞的read調用會傳回錯誤ECONNREST
恩,socket對這些錯誤還是有一定的回報能力的,前提是在對面不可達時你依然做了一次write調用,而不是輪詢或是阻塞在read上,那麼總是會在重傳的周期内檢測出錯誤。如果沒有那次write調用,應用層永遠不會收到連接配接錯誤的通知。
write的錯誤最終通過read來通知應用層,有點陰差陽錯?
還需要做什麼?
至此,我們知道了僅僅通過read/write來檢測異常情況是不靠譜的,還需要一些額外的工作:
1. 使用TCP的KEEPALIVE功能?
cat /proc/sys/net/ipv4/tcp_keepalive_time
cat /proc/sys/net/ipv4/tcp_keepalive_intvl
cat /proc/sys/net/ipv4/tcp_keepalive_probes
以上參數的大緻意思是:keepalive routine每2小時(7200秒)啟動一次,發送第一個probe(探測包),如果在75秒内沒有收到對方應答則重發probe,當連續9個probe沒有被應答時,認為連接配接已斷。(此時read調用應該能夠傳回錯誤,待測試)
但在我印象中keepalive不太好用,預設的時間間隔太長,又是整個TCP/IP棧的全局參數:修改會影響其他程序,Linux的下似乎可以修改per socket的keepalive參數?(希望有使用經驗的人能夠指點一下),但是這些方法不是portable的。
2. 進行應用層的心跳
嚴格的網絡程式中,應用層的心跳協定是必不可少的。雖然比TCP自帶的keep alive要麻煩不少,但有其最大的優點:可控。
當然,也可以簡單一點,針對連接配接做timeout,關閉一段時間沒有通信的”空閑“連接配接。這裡可以參考一篇文章:
Muduo 網絡程式設計示例之八:Timing wheel 踢掉空閑連接配接 by 陳碩