博客 / 詳情

返回

POSIX兼容系統上read和write系統調用的行為總結

關於UNIX和Linux的宣傳語中,一切皆文件應該是最廣為人知的一句。

不管是普通文件,還是硬件設備、管道、網絡套接字,在Linux甚至還有信號和定時器都共享一套相似的api,大家可以用類似的代碼完成各種不同的任務,大大簡化了代碼複雜度和學習成本。

當然這只是理想中的情況,現實是普通文件和硬件設備是兩種完全不同的東西,普通文件和網絡套接字尤其是UDP協議的那種更是風馬牛不相及,強行把這些行為屬性完全不同的事物整合進同一套api,導致了read/write/send/recv這幾個系統調用的行為極其複雜,bug叢生,更是給很多新手帶來了無盡的困擾。

而且由於系統差異和資料分散,這類問題就連求助於AI都很難得到有效解決。這也是我寫這篇文章的原因。

進入正題之前我們先限定一下討論範圍和實驗環境,因為這個主題太複雜了包羅萬象是不可能的。

討論範圍:行為會基於POSIX 2008這版標準進行討論,但也會加入一下Linux和macOS上特有的行為,這些會特別標註。

實驗環境:Linux環境內核版本高於4.0即可,macOS 15及以上。

基礎回顧之部分讀部分寫

有一些重要的概念會貫穿整個我們對系統調用行為的討論,這裏必須先介紹一下。

我們先來看看接下來要説的系統調用長什麼樣:

#include <sys/types.h>
#include <unistd.h>

// 從文件描述符裏讀數據
ssize_t read(int fd, void *buf, size_t nbyte);

// 向文件描述符裏寫數據
ssize_t write(int fd, const void *buf, size_t nbyte);

// 從套接字中讀取數據,不可用於套接字以外
ssize_t recv(int sockfd, void *buf, size_t nbyte, int flags);
// 向套接字寫入數據,不可用於套接字以外
ssize_t send(int sockfd, const void *buf, size_t nbyte, int flags);

他們長得很像,核心邏輯也差不多——圍繞一塊nbyte長度的緩衝區進行操作,把數據從緩衝區寫入描述符,或者從描述符裏讀取數據填進緩衝區。這些系統調用是文件和網絡io的核心。

通常讀取類的系統調用會盡可能多地讀入數據直到填滿緩衝區,而寫入類的系統調用則會盡可能把緩衝區裏所有的數據寫入描述符。

然而現實是POSIX除了少數操作之外並沒有規定讀寫操作不能被打斷,因此經常會出現讀或者寫了一半時操作被中斷的情況:

  1. 進程收到了信號,導致系統調用中斷,當然一部分系統會在中斷後自動重啓系統調用,但這個行為是可配置且有系統差異的,所以我們不能忽略這種中斷場景
  2. 網絡套接字的緩衝區中只有少量數據可讀/少量空間可寫,系統調用在一些情況下中止並返回
  3. 讀寫中遇到錯誤,比如網絡中斷、硬盤故障等

這些情況會導致緩衝區裏的數據只有一部分被寫入目標或者只從目標中讀取了一部分數據沒能填滿緩衝區,簡單的説就是調用返回的值比nbyte小且沒有設置errno,我們把這些情況統一叫做部分讀和部分寫,英文叫short read/write或者partial read/write。

這不是bug,而是需要處理的正常的系統行為,尤其是在非阻塞io中。不同類型的對象在這方面有很大的行為差異,這也是本文下面要討論的內容。

普通文件上的讀寫行為

普通文件是指在你硬盤裏的那些文本文件、程序代碼、音樂、圖片、視頻、PPT之類的東西。這些統稱regular files。

普通文件上沒有非阻塞io,且無法被poll、select監聽。bsd系統上的kqueue對普通文件做了擴展,但這不屬於POSIX規範且超出了討論範圍,我們就不提了。

雖然普通文件特性少,也因此read和write在它們上的行為更直觀,也更符合預期。

read的行為:

  1. 幾乎總是阻塞到填滿緩衝區
  2. 文件可讀取內容比緩衝區小的時候會把文件中剩下可讀的數據全部讀取,然後返回,這是返回值小於緩衝區大小
  3. 讀取過程中可以被中斷
  4. 如果讀取出錯了,則返回值是-1,errno會被設置,緩衝區裏很可能會有垃圾數據
  5. 如果返回0(EOF,end-of-file),説明文件所有內容已經讀取完畢,這也是正常情況,errno不會被設置

從POSIX標準和Linux的文檔上來看,read是會有部分讀存在的,然而標準是標準實現是實現,現實情況是不管是macOS的APFS上還是Linux上常見的文件系統,read一但準備工作完成就不可被信號中斷,因此部分讀無法發生。

以Linux為例,所有想利用page cache的文件系統在進行文件讀寫時都會調用filemap_read,這個函數會接着調用filemap_get_pagesfilemap_get_pages裏有完成讀取的主要邏輯,而在它的最開頭處,初始化完所有資源就會調用fatal_signal_pendingfatal_signal_pending會讓當前線程屏蔽包括SIGKILL在內的所有信號。

這意味着一但read開始,就會忽略所有信號,read也就不可能存在讀取一部分數據後被中斷的場景。這麼做當然是為了數據一致性和安全考慮,雖然代價是和標準有了小小的衝突,但也無可厚非。

想要測試也很簡單,準備一個1GB的文件,然後一個線程每次讀寫1MB,並且讓另一個線程不停發信號,理論上下面這段代碼不應該看到有“Short Read”的輸出:

#include <iostream>
#include <thread>
#include <atomic>
#include <signal.h>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <chrono>

std::atomic<int> sigint_count{0};

void handle_sigint(int signo) {
    if (signo == SIGINT) {
        sigint_count++;
    }
}

int main() {
    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sa.sa_handler = handle_sigint;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0; // 不設置SA_RESTART,這會禁止系統調用自動重啓

    if (sigaction(SIGINT, &sa, nullptr) == -1) {
        perror("sigaction");
        return 1;
    }

    int fd = open("test.data", O_RDONLY); // 1GB
    if (fd < 0) {
        perror("open");
        return 1;
    }
    pid_t pid = getpid();

    // 每隔100ns就發一次信號
    std::thread([pid]() {
        while (true) {
            kill(pid, SIGINT);
            std::this_thread::sleep_for(std::chrono::nanoseconds(100));
        }
    }).detach();

    const size_t buffer_size = 1024 * 1024; // 1MB
    char* buffer = new char[buffer_size];

    ssize_t bytes_read;
    while (sigint_count.load()<=1);
    while ((bytes_read = read(fd, buffer, buffer_size)) > 0) {
        if (bytes_read != 1024*1024) {
                std::cout << "Short Read: " << bytes_read << " bytes\n";
        }
    }

    if (bytes_read < 0) {
        perror("read");
    }

    close(fd);
    delete[] buffer;

    std::cout << "SIGINT received: " << sigint_count.load() << " times\n";

    return 0;
}

輸出:

$ g++-15 -Wall -Wextra -std=c++20 read.cpp
$ head -c 1073741824 /dev/random > test.data
$ ./a.out

SIGINT received: 1099 times

可以看到我們發送了1000多次信號,沒有對read產生任何影響。

説完了read説説write。

write在普通文件上的行為:

  1. 正常情況下阻塞到buff全部寫入文件
  2. 出錯的時候直接返回-1,比如磁盤空間不夠(一個字節都寫不進去)、沒有寫入權限等,並設置對應的errno。
  3. 可以被信號中斷,這時會發生部分寫
  4. 如果信號在任何數據實際寫入之前收到,write返回-1並且設置errno為EINTR。
  5. 如果磁盤的空間不夠或者進程有寫入配額限制,則發生部分寫入,還有多少空間就寫入多少,write在本次寫入後正常返回

我們可以輕鬆得用ulimit來限制進程可寫入的文件大小並模擬磁盤空間不夠的情況:

#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>

int main() {
    int fd = open("test.data", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd < 0) {
        perror("open");
        return 1;
    }

    const size_t chunk_size = 12345;
    const size_t total_writes = 10000;
    char buffer[chunk_size];
    memset(buffer, 'c', chunk_size);

    for (size_t i = 0; i < total_writes; ++i) {
        ssize_t written = write(fd, buffer, chunk_size);
        if (written < 0) {
            perror("write");
            break;
        }
        if (written != chunk_size) {
            std::cout << "Short Write: " << written << " bytes\n";
            std::cout << "Count of write: " << i << "\n";
        }
    }

    close(fd);

    return 0;
}

運行:

$ ulimit -f 102400
$ ./a.out
Short Write: 11515 bytes
Count of write: 8493
core dump‰./a.out

可以看到最後一次寫入已經沒有足夠的空間寫入12345字節了,所以只寫入了11515字節。真實的磁盤耗盡會在部分寫入發生後下一次write理論上應該返回-1並設置errno,但ulimit模擬的會直接殺死進程,因為POSIX要求在這種情況下發送信號SIGXFSZ給進程,這個信號的默認處理行為的進程崩潰。

和read一樣,對於普通文件的write在準備工作完成後也會屏蔽掉所有信號,這使得在macOS和Linux普通文件的寫也不會發生部分寫入。

對於普通文件,write還有一個特殊行為:如果第三個參數是0,則不寫入任何數據,但會探測寫入操作是否會出錯,比如硬盤掛掉了或者權限不夠。

總結:對於普通文件,macOS和Linux上的read/write總是會讀取/寫入和buf長度相等的數據。

不對普通文件處理部分讀和部分寫正在成為越來越多人的共識,畢竟代碼寫起來簡單。但標準留了口子,為了可移植性開發者最好還是不要對此做出過多假設為好。

管道上的讀寫行為

管道大家應該不陌生,POSIX規定了兩種類型的管道pipe和FIFO。

pipe是匿名管道,FIFO是有名字的且需要在支持管道文件的文件系統上生成一個對應的實體。除此之外兩者行為上沒有差別。所以這一節兩者合併在一起討論。

管道的讀寫行為和普通文件不同,也和下一節要説的套接字有些出入,所以需要單獨拿出來作為一節內容。

管道有一個固定的總容量,超過此容量的數據無法繼續寫入。管道也支持非阻塞io。管道上的讀寫行為還受到讀寫端是否開啓的影響,所以整體上管道的複雜度比普通文件高了至少一個數量級。

先總結一下讀的行為:

條件 read行為 read返回值 errno 是否是部分讀
寫端的管道被關閉 遇到EOF 0 不設置
同步io,管道里沒有數據 阻塞到有數據能讀為止,但有多少讀多少,不要求填滿buf,可被中斷 <= buf長度 不設置
非阻塞io,管道中沒有數據 直接出錯 -1 EAGAIN
同步io,管道里有數據 不阻塞,有多少讀多少,不要求填滿buf,可被中斷 <= buf長度 不設置
非阻塞io,管道中有數據 不阻塞,有多少讀多少,不要求填滿buf,可被中斷 <= buf長度 不設置
任何模式下,讀取開始前被信號中斷 直接出錯 -1 EINTR
任何模式下,讀取了部分數據,被信號中斷 正常返回 <= buf長度 不設置

最後一種情況其實涵蓋在第四和第五中了,但我還是單獨列出方便大家理解。

簡單地説,管道的讀大部分都是部分讀,管道里有多少數據就讀多少,唯一會發生阻塞的場景是管道里一點數據都沒有的時候。

看個例子就知道了,管道里只有11個字節數據,我們的讀取buf有1024長,但和讀普通文件不一樣,read讀完11字節就正常返回:

#define _GNU_SOURCE // for pipe2
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main()
{
    int pair[2] = {-1, -1};
    if (pipe2(pair, 0) < 0) { // 沒有設置任何標誌,默認是同步io
        perror("pipe2");
        return 1;
    }

    if (write(pair[1], "Hello World", 11) < 0) {
        perror("write");
        return 1;
    }

    char buf[1024] = {0};
    int n = read(pair[0], buf, 1024);
    if (n < 0) {
        perror("read");
        return 1;
    }
    printf("read %d bytes\n", n); // output: read 11 bytes

    // read(pair[0], buf, 1024);
    // 已經沒數據了,同步io下程序會阻塞在這,非阻塞io下返回-1,errno被設置為EAGAIN
}

寫入的行為則比讀取要複雜的多,會同時受到管道容量、原子寫、信號的影響。

原子寫是管道特有的概念:任何大小小於等於PIPE_BUF大小的寫操作都是原子的,要麼全部寫入要麼徹底失敗,且不可被中斷。

PIPE_BUF的值在不同系統上也是不同的,在macOS上是512字節,而在Linux上是4096。管道的總容量也是一樣的,在Linux是16個page size大小,而且容量可以手動修改,在macOS管道默認大小16kb,但可以擴展到64kb。

寫入行為總結:

條件 write行為 write返回值 errno 是否是部分寫
讀取端關閉 直接出錯 -1 EPIPE
同步io,原子寫,管道中容量足夠容納所有內容 不阻塞,原子地寫入所有數據,不可中斷 len(buf) 不設置
同步io,原子寫,管道沒有容量或者容量不足以容納所有內容 阻塞到所有內容可被寫入為止,不可中斷 len(buf) 不設置
非阻塞io,原子寫,管道中容量足夠容納所有內容 不阻塞,原子地寫入所有數據,不可中斷 len(buf) 不設置
非阻塞io,原子寫,管道沒有容量或者容量不足以容納所有內容 不阻塞,直接出錯 -1 EAGAIN
同步io,普通寫,管道中容量足夠容納所有內容 不阻塞,寫入所有數據,可中斷 <= len(buf) 不設置
同步io,普通寫,管道沒有容量或者容量不足以容納所有內容 阻塞到所有內容可都寫入為止,可中斷 <= len(buf) 不設置
非阻塞io,普通寫,管道中容量足夠容納所有內容 不阻塞,寫入所有數據,可中斷 <= len(buf) 不設置
非阻塞io,普通寫,管道沒有任何容量 不阻塞,直接出錯 -1 EAGAIN
非阻塞io,普通寫,管道容量不足以寫入所有數據 不阻塞,有多少寫入多少,可中斷 <= len(buf) 不設置
任何模式,寫入沒開始前被信號中斷 直接出錯 -1 EINTR

可以看到部分寫主要發生在非原子寫的情況下。看一個非阻塞io時容量不夠導致部分寫的例子:

#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>

#define PIPE_MAX (4096*16)
#ifdef __linux__
// #include <linux/limits.h>
#define PIPE_BUF 4096
#else
#define PIPE_BUF 512
#endif


int main()
{
    int pair[2] = {-1, -1};
    if (pipe(pair) < 0) {
        perror("pipe2");
        return 1;
    }
    // 設置為非阻塞io,macOS不支持pipe2,為了跨平台只能用這種原始辦法
    if (fcntl(pair[0], F_SETFL, fcntl(pair[0], F_GETFL) | O_NONBLOCK) < 0) {
        perror("fcntl pair[0]");
        return 1;
    }
    if (fcntl(pair[1], F_SETFL, fcntl(pair[1], F_GETFL) | O_NONBLOCK) < 0) {
        perror("fcntl pair[1]");
        return 1;
    }

    // 非原子寫入,因此發生部分寫
    char buf[PIPE_MAX-1] = {0};
    memset(buf, 'c', sizeof(buf));
    if (write(pair[1], buf, PIPE_MAX-1) != PIPE_MAX-1) {
        printf("this can not be a short write\n");
        return 1;
    }
    char new_buf[PIPE_BUF+1] = {0};
    int n = write(pair[1], new_buf, PIPE_BUF+1);
    if (n < 0) {
        perror("write");
        return 1;
    }
    printf("short %d bytes\n", n);

    if (read(pair[0], read_buf, PIPE_BUF-100) != PIPE_BUF-100) {
        printf("this can not be a short read\n");
        return 1;
    }

    // 原子寫入會立即失敗
    char atomic_buf[PIPE_BUF];
    memset(atomic_buf, 'c', PIPE_BUF);
    n = write(pair[1], atomic_buf, PIPE_BUF);
    if (n < 0) {
        perror("atomic write failed");
        return 0;
    } else {
        printf("no way!\n");
        return 1;
    }
}

程序首先寫入數據,只留一字節給管道,然後非阻塞寫入一個比原子寫限制大一字節的數據,這時候程序就會發生部分寫,只寫入一字節。如果這裏是同步io的話程序則會阻塞住直到剩下的所有數據都能寫入。接着我們把讀取PIPE_BUF-100的數據,現在管道的容量只有PIPE_BUF-100字節,然後又再往管道里原子寫入PIPE_BUF長度的數據,這一步應該直接失敗。

運行結果:

$ ./a.out

short 1 bytes
atomic write failed: Resource temporarily unavailable

輸出中的Resource temporarily unavailable就是EAGAIN的文字描述。可見即使還有空間,只要不能容納下原子寫入要求的全部數據,就會立即失敗。

總結:儘量每次讀寫管道都使用PIPE_BUF大小的buf可以免去很多麻煩,但我還是建議每次讀寫之後檢查返回值和errno,以免發生問題,畢竟讀寫加起來差不多有20種情況存在了。這也是APUE這本書推薦的做法。

有一點需要注意,POSIX規定了所有errno被設置成EPIPE的場景,進程都會收到SIGPIPE,這個信號默認行為會導致進程崩潰。但這個信號並不意味着程序發生了無法挽回的錯誤,所以常見的做法是徹底屏蔽它然後檢查write調用的返回值和errno。

UDP協議套接字上的讀寫行為

終於來到最複雜的套接字了,這裏説的套接字包含網絡類型為INETUDS這兩種,儘管他們的實現完全不同處理數據的方式也大相徑庭,但在readwritesendrecv這些系統調用上的行為是一樣的。

POSIX規定readwrite如果操作對象是socket,那麼效果等同於調用recvsend。所以在socket的兩節裏我們只討論recvsend

對UDP套接字的操作是比較簡單的,每次recv和send都會讀取/發送一個UDP數據報,而且這個操作是原子的不可中斷。

這意味send會把buf中所有東西全部寫入後才會成功返回,而且寫入一但開始就不可被中斷。所以不存在部分寫。

而recv則會盡量把下一個待讀取的數據報全部讀入緩衝區,如果數據報的大小超過緩衝區大小,則會截斷,截斷之後數據報剩餘的數據會被全部丟棄,recv在截斷時也會正常返回。recv同樣一但開始讀取就不可中斷,所以不存在部分讀。

UDP有讀寫緩衝區的概念,這會影響它在不同io模式下的行為:

  1. 如果讀緩衝區是空的,同步io時recv會一直阻塞到有數據進來才返回;非阻塞io下則直接報錯並設置EAGAIN
  2. 如果讀緩衝區有數據,不管什麼模式下都會立即讀取一個數據報並返回
  3. 如果發送緩衝區是滿的,同步io時send會一直阻塞到所有數據都能寫入為止;而非阻塞下會直接報錯並設置EAGAIN
  4. 如果發送緩衝區有空間但不足以寫入所有內容,同步io的send會阻塞到緩衝區有足夠空間,然後一次性寫入所有內容;非阻塞io時則直接報錯並設置EAGAIN
  5. 如果發送緩衝區有空間寫入所有數據,則任意模式都不會阻塞,會立即把所有數據寫入並返回
  6. 向沒有服務監聽的地址端口寫數據並不會發生錯誤,這是udp協議的特性,除非你把套接字的對端地址進行了綁定

總體UDP很簡單沒有部分讀寫問題,只有數據截斷需要特別注意。這在後文會講。

TCP協議套接字上的讀寫行為

TCP是這些總結裏面最複雜的,因為它受io模式和信號影響,同時也有讀寫緩衝區的概念,並且TCP是面向連接的協議,連接狀態還會額外影響讀寫的行為。

場景實在太多,用文字描述會非常費篇幅,因此我們直接上表格:

io模式 讀緩衝區狀態 連接狀態 recv行為 是否能被中斷 recv返回值 errno 是否是部分讀
同步 緩衝區空 正常連接 阻塞到有數據為止,然後儘可能多讀取信息,直到緩衝區裏沒數據或者buf填滿 可中斷 <= len(buf) 不設置
同步 緩衝區有數據或者滿 正常連接 不阻塞,儘可能多讀取信息,直到緩衝區裏沒數據或者buf填滿 可中斷 <= len(buf) 不設置
同步 緩衝區空 連接已經關閉 不阻塞,直接返回 可中斷 0 不設置
同步 緩衝區有數據或者滿 連接已經關閉 不阻塞,儘可能多讀取信息,直到緩衝區裏沒數據或者buf填滿 可中斷 <= len(buf) 不設置
非阻塞 緩衝區空 正常連接 直接出錯 可中斷 -1 EAGAIN
非阻塞 緩衝區有數據或者滿 正常連接 不阻塞,儘可能多讀取信息,直到緩衝區裏沒數據或者buf填滿 可中斷 <= len(buf) 不設置
非阻塞 緩衝區空 連接已經關閉 不阻塞,直接返回 可中斷 0 不設置
非阻塞 緩衝區有數據或者滿 連接已經關閉 不阻塞,儘可能多讀取信息,直到緩衝區裏沒數據或者buf填滿 可中斷 <= len(buf) 不設置
任意 緩衝區空 連接異常終止收到RST 直接出錯 可中斷 -1 ECONNRESET
任意 緩衝區有數據或者滿 連接異常終止收到RST 不阻塞,儘可能多讀取信息,直到緩衝區裏沒數據或者buf填滿 可中斷 <= len(buf) 不設置
任意 任意 本地close了socket,然後繼續調用recv 直接出錯 可中斷 -1 EBADF

recv返回0(EOF)説明所有的數據都已經被讀取,連接的生命週期也應該正常結束了。

由此可見,除了部分異常情況,TCP下幾乎所有的讀都是部分讀而且可被信號中斷,因此必須去檢查recv的返回值並做處理。

寫入時的情況類似:

io模式 寫緩衝區狀態 連接狀態 send行為 是否能被中斷 send返回值 errno 是否是部分寫
同步 緩衝區有足夠空間寫入全部數據 正常連接 不阻塞,寫入全部數據 可中斷 <= len(buf) 不設置
同步 緩衝區有空間但不能寫入全部數據或者滿 正常連接 先寫入數據,然後阻塞到緩衝區有空間,接着寫入,循環往復直到全部寫入 可中斷 <= len(buf) 不設置
非阻塞 緩衝區有足夠空間寫入全部數據 正常連接 不阻塞,寫入全部數據 可中斷 <= len(buf) 不設置
非阻塞 緩衝區有空間但不能寫入全部數據 正常連接 不阻塞,儘可能多寫入然後返回 可中斷 < len(buf) 不設置
非阻塞 緩衝區滿 正常連接 直接出錯 可中斷 -1 EAGAIN
任意 任意 連接已經關閉 直接出錯 可中斷 -1 EPIPE
任意 任意 連接異常終止收到RST 直接出錯 可中斷 -1 ECONNRESET
任意 任意 本地close了socket,然後繼續調用send 直接出錯 可中斷 -1 EBADF

send要簡單一些,因為它對連接狀態的要求更為嚴格。同步io下send會盡量發生全部數據,但會被信號中斷;非阻塞io下則是能寫多少是多少,幾乎都是部分寫。

所以針對tcp必須檢查所有讀寫操作的返回值和errno,這也是為什麼UNP這本網絡編程的名著會在頭兩章就給出下面這樣的幫助函數:

/* Like write(), but retries in case of partial write */
ssize_t writen(int fd, const void *buf, size_t count)
{
	size_t n = 0;
	while (count > 0) {
		int r = write(fd, buf, count);
		if (r < 0) {
			if (errno == EINTR)
				continue;
			return r;
		}
		if (r == 0)
			return n;
		buf = (const char *)buf + r;
		count -= r;
		n += r;
	}
	return n;
}

這樣的檢查和處理邏輯每次都寫一遍代碼很快就能進化成屎山了,所以作者給出了這個函數,而且這種幫助函數在c/c++項目中很常見。

不同讀寫行為導致的問題

不同的讀寫行為經常會帶來心智負擔,最後在代碼裏留下問題。

比如前文中提到的UDP數據截斷問題。我剛入行的時候就被坑過,當時我寫的代碼在解析一些特定種類的數據報信息時算錯了數據長度,導致讀取用的緩衝區設置小了,這些種類的數據報都被截斷了。然而recvrecvFrom都不會報告截斷問題還會正常返回,這導致調試過程異常艱難,最後還是有經驗的前輩和我一起抓包對比接收到的數據才發現發送的數據比接收的大,這才想到了是recv截斷數據的問題。

當然recvmsgrecvmmsg這兩個系統調用會在返回的結構體的flags字段裏設置MSG_TRUNC標誌來表示數據報被截斷,但相對來説recv和read因為接口更簡單所以大多數人優先選擇使用它們,我也不例外。

除了上面的UDP數據截斷問題,部分寫入也會出問題,比如不檢查返回值導致需要的數據沒有全部寫入,這種問題在新接觸TCP編程的人的代碼裏很常見。

不過物極必反,有時候太謹慎也不好,比如最近我在審查golang的代碼時發現有人把上一節的writen函數搬到go裏了,最後搞出了下面這樣的代碼:

func (c *Client) sendData(data []byte) error {
    header := 從data裏生成header
    if err := util.WriteAll(c.tcpConn, header); err != nil {
        return err
    }
    if err := util.WriteAll(c.tcpConn, data); err != nil {
        return err
    }
    return nil
}

代碼看起來很清晰,開發者還想到了TCP的部分寫入問題,簡直無可挑剔啊。

然而這是go語言,go對io做了很多封裝,把異步非阻塞io操作封裝成了同步操作,因此部分寫問題被已經考慮到並且強制要求所有實現io.Writer接口的類型保證不出現部分寫的:

$ go doc io.Writer

package io // import "io"

type Writer interface {
        Write(p []byte) (n int, err error)
}
    Writer is the interface that wraps the basic Write method.

    Write writes len(p) bytes from p to the underlying data stream. It returns
    the number of bytes written from p (0 <= n <= len(p)) and any error
    encountered that caused the write to stop early. Write must return a non-nil
    error if it returns n < len(p). Write must not modify the slice data,
    even temporarily.

    Implementations must not retain p.

其中Write must return a non-nil error if it returns n < len(p).就説明了,如果寫操作沒有出錯,則數據必須全部寫入,因此沒有部分寫問題。

實際上net.TCPConn也是這麼做的:

func (fd *FD) Write(p []byte) (int, error) {
	...
	var nn int
	for {
		max := len(p)
		if fd.IsStream && max-nn > maxRW {
			max = nn + maxRW
		}
		n, err := ignoringEINTRIO(syscall.Write, fd.Sysfd, p[nn:max])
		if n > 0 {
			if n > max-nn {
				// This can reportedly happen when using
				// some VPN software. Issue #61060.
				// If we don't check this we will panic
				// with slice bounds out of range.
				// Use a more informative panic.
				panic("invalid return from write: got " + itoa.Itoa(n) + " from a write of " + itoa.Itoa(max-nn))
			}
			nn += n
		}
		if nn == len(p) {
			return nn, err
		}
		if err == syscall.EAGAIN && fd.pd.pollable() {
			if err = fd.pd.waitWrite(fd.isFile); err == nil {
				continue
			}
		}
		if err != nil {
			return nn, err
		}
		if n == 0 {
			return nn, io.ErrUnexpectedEOF
		}
	}
}

這是一個非阻塞版本的writen,在收到EAGAIN的時候會調用poll之類的系統調用來等待文件描述符可寫。所以util.WriteAll是完全多餘的並會成為性能殺手。

golang這麼做很正常,因為在同步io模式下,除了被信號中斷,幾乎所有的寫入操作都是保證buf裏的數據全部寫入才返回給調用者的,模擬同步io的go沒有不這樣做的理由。另一個原因是這樣可以減輕開發者的心智負擔。當然如果一個第三方庫沒有按要求處理部分寫,那就會引發新的問題了,但這屬於是第三方庫的責任。

總結

這篇文章總結了常見對象上read/write系統調用的行為,對於日常的Linux/Unix程序開發來説足夠了。

然而還有很多東西沒被覆蓋,比如Linux上的signalfd和timerfd,比如虛擬文件系統和設備文件,在這些資源上io相關的系統調用又是另一種情況了。尤其是虛擬文件系統,稍有不慎就會出錯,因此我準備另寫一篇文章。對於這些POSIX中未明確定義的或者操作系統特有的對象,開發者只能自己去找相關的文檔看了。

雖然我總結的只有readwrite,但整個函數族的行為是一致的,只有很微小的區別,比如read的行為對於preadreadv也都適用,write的總結也對writevpwrite等適用。

這就是一切皆文件的真相,把不相干的東西雜糅在一起除了會給使用者帶來麻煩之外並不能帶來多少收益,這也時刻提醒着所有開發者不要為了抽象而抽象,抽象和設計始終是為使用服務的。

參考

https://pubs.opengroup.org/onlinepubs/9699919799/functions/

user avatar cyningsun 頭像 roseduan 頭像 xushuhui 頭像 dubingxuan 頭像 yidianyihengchang 頭像 guyu_5d2e806c57ac8 頭像 gangyidesongshu 頭像
7 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.