Unix/Linux中的read和write函數


文件描述符

  對於內核而言,所有打開的文件都通過文件描述符引用。文件描述符是一個非負整數。當打開一個現有文件或創建一個新文件時,內核向進程返回一個文件描述符。當讀或寫一個文件時,使用open或create返回的文件描述符表示該文件,將其作為參數傳給read或write函數。

write函數

   write函數定義如下:

#include <unistd>
ssize_t write(int filedes, void *buf, size_t nbytes);
// 返回:若成功則返回寫入的字節數,若出錯則返回-1
// filedes:文件描述符
// buf:待寫入數據緩存區
// nbytes:要寫入的字節數

  同樣,為了保證寫入數據的完整性,在《UNIX網絡編程 卷1》中,作者將該函數進行了封裝,具體程序如下:

 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 }
View Code

read函數

  read函數定義如下:

#include <unistd>
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》中,作者將該函數進行了封裝,以確保數據讀取的完整,具體程序如下:

 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 }
View Code

  

  本文下半部分摘自博文淺談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 陳碩

參考資料

  《UNIX環境高級編程》

  《UNIX網絡編程 卷1》

  淺談TCP/IP網絡編程中socket的行為

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM