Linux網絡編程 - 非阻塞I/O

阻塞 VS 非阻塞

當應用程序調用阻塞 I/O 完成某個操作時,應用程序會被掛起,等待內核完成操作,感覺上應用程序像是被“阻塞”了一樣。實際上,內核所做的事情是將 CPU 時間切換給其他有需要的進程,網絡應用程序在這種情況下就會得不到 CPU 時間做該做的事情。

非阻塞 I/O 則不然,當應用程序調用非阻塞 I/O 完成某個操作時,內核立即返回,不會把 CPU 時間切換給其他進程,應用程序在返回後,可以得到足夠的 CPU 時間繼續完成其他事情。

按照使用場景,非阻塞 I/O 可以被用到讀操作、寫操作、接收連接操作和發起連接操作上。

非阻塞 I/O

  • 讀操作

如果套接字對應的接收緩衝區沒有數據可讀,在非阻塞情況下 read 調用會立即返回,一般返回 EWOULDBLOCK 或 EAGAIN 出錯信息。在這種情況下,出錯信息是需要小心處理,比如後面再次調用 read 操作,而不是直接作爲錯誤直接返回。

  • 寫操作

在阻塞 I/O 情況下,write 函數返回的字節數,和輸入的參數總是一樣的。如果返回值總是和輸入的數據大小一樣,write 等寫入函數還需要定義返回值嗎?

在非阻塞 I/O 的情況下,如果套接字的發送緩衝區已達到了極限,不能容納更多的字節,那麼操作系統內核會盡最大可能從應用程序拷貝數據到發送緩衝區中,並立即從 write 等函數調用中返回。拷貝動作發生的瞬間,有可能一個字符也沒拷貝,有可能所有請求字符都被拷貝完成,那麼這個時候就需要返回一個數值,告訴應用程序到底有多少數據被成功拷貝到了發送緩衝區中,應用程序需要再次調用 write 函數,以輸出未完成拷貝的字節。

write 等函數是可以同時作用到阻塞 I/O 和非阻塞 I/O 上的,爲了複用一個函數,處理非阻塞和阻塞 I/O 多種情況,設計出了寫入返回值,並用這個返回值表示實際寫入的數據大小。

非阻塞 I/O 和阻塞 I/O 處理的方式是不一樣的。非阻塞 I/O :拷貝→返回→再拷貝→再返回。而阻塞 I/O :拷貝→直到所有數據拷貝至發送緩衝區完成→返回。

不過在實戰中,你可以不用區別阻塞和非阻塞 I/O,使用循環的方式來寫入數據就好了。只不過在阻塞 I/O 的情況下,循環只執行一次就結束了。

/* 向文件描述符fd寫入n字節數 */
ssize_t writen(int fd, const void * data, size_t n)
{
    size_t      nleft;
    ssize_t     nwritten;
    const char  *ptr;

    ptr = data;
    nleft = n;
    //如果還有數據沒被拷貝完成,就一直循環
    while (nleft > 0) {
        if ( (nwritten = write(fd, ptr, nleft)) <= 0) {
           /* 這裏EAGAIN是非阻塞non-blocking情況下,通知我們再次調用write() */
            if (nwritten < 0 && errno == EAGAIN)
                nwritten = 0;      
            else
                return -1;         /* 出錯退出 */
        }

        /* 指針增大,剩下字節數變小*/
        nleft -= nwritten;
        ptr   += nwritten;
    }
    return n;
}

 關於 read 和 write 還有幾個結論,你需要把握住:

  1. read 總是在接收緩衝區有數據時就立即返回,不是等到應用程序給定的數據充滿才返回。當接收緩衝區爲空時,阻塞模式會等待,非阻塞模式立即返回 -1,並有 EWOULDBLOCK 或 EAGAIN 錯誤。
  2. 和 read 不同,阻塞模式下,write 只有在發送緩衝區足以容納應用程序的輸出字節時才返回;而非阻塞模式下,則是能寫入多少就寫入多少,並返回實際寫入的字節數。
  3. 阻塞模式下的 write 有個特例, 就是對方主動關閉了套接字,這個時候 write 調用會立即返回,並通過返回值告訴應用程序實際寫入的字節數,如果再次對這樣的套接字進行 write 操作,就會返回失敗。失敗是通過返回值 -1 來通知到應用程序的。
  • accept

當 accept 和 I/O 多路複用 select、poll 等一起配合使用時,如果在監聽套接字上觸發事件,說明有連接建立完成,此時調用 accept 肯定可以返回已連接套接字。這樣看來,似乎把監聽套接字設置爲非阻塞,沒有任何好處。

爲了說明這個問題,我們構建一個客戶端程序,其中最關鍵的是,一旦連接建立,設置 SO_LINGER 套接字選項,把 l_onoff 標誌設置爲 1,把 l_linger 時間設置爲 0。這樣,連接被關閉時,TCP 套接字上將會發送一個 RST。

struct linger ling;
ling.l_onoff = 1; 
ling.l_linger = 0;
setsockopt(socket_fd, SOL_SOCKET, SO_LINGER, &ling, sizeof(ling));
close(socket_fd);

服務器端使用 select I/O 多路複用,不過,監聽套接字仍然是 blocking 的。如果監聽套接字上有事件發生,休眠 5 秒,以便模擬高併發場景下的情形。

if (FD_ISSET(listen_fd, &readset)) {
    printf("listening socket readable\n");
    sleep(5);
    struct sockaddr_storage ss;
    socklen_t slen = sizeof(ss);
    int fd = accept(listen_fd, (struct sockaddr *) &ss, &slen);

這裏的休眠時間非常關鍵,這樣,在監聽套接字上有可讀事件發生時,並沒有馬上調用 accept。由於客戶端發生了 RST 分節,該連接被接收端內核從自己的已完成隊列中刪除了,此時再調用 accept,由於沒有已完成連接(假設沒有其他已完成連接),accept 一直阻塞,更爲嚴重的是,該線程再也沒有機會對其他 I/O 事件進行分發,相當於該服務器無法對新連接和其他 I/O 進行服務。

如果我們將監聽套接字設爲非阻塞,上述的情形就不會再發生。只不過對於 accept 的返回值,需要正確地處理各種看似異常的錯誤,例如忽略 EWOULDBLOCK、EAGAIN 等。

因此,一定要將監聽套接字設置爲非阻塞的,儘管這裏休眠時間 5 秒有點誇張,但是在極端情況下處理不當的服務器程序是有可能碰到文稿中例子所闡述的情況,爲了讓服務器程序在極端情況下工作正常,這點工作還是非常值得的。

  • connect

在非阻塞 TCP 套接字上調用 connect 函數,會立即返回一個 EINPROGRESS 錯誤。TCP 三次握手會正常進行,應用程序可以繼續做其他初始化的事情。當該連接建立成功或者失敗時,通過 I/O 多路複用 select、poll 等可以進行連接的狀態檢測。

非阻塞 I/O + select 多路複用

#define MAX_LINE 1024
#define FD_INIT_SIZE 128

char rot13_char(char c) {
    if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
        return c + 13;
    else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
        return c - 13;
    else
        return c;
}

//數據緩衝區
struct Buffer {
    int connect_fd;  //連接字
    char buffer[MAX_LINE];  //實際緩衝
    size_t writeIndex;      //緩衝寫入位置
    size_t readIndex;       //緩衝讀取位置
    int readable;           //是否可以讀
};

struct Buffer *alloc_Buffer() {
    struct Buffer *buffer = malloc(sizeof(struct Buffer));
    if (!buffer)
        return NULL;
    buffer->connect_fd = 0;
    buffer->writeIndex = buffer->readIndex = buffer->readable = 0;
    return buffer;
}

void free_Buffer(struct Buffer *buffer) {
    free(buffer);
}

int onSocketRead(int fd, struct Buffer *buffer) {
    char buf[1024];
    int i;
    ssize_t result;
    while (1) {
        result = recv(fd, buf, sizeof(buf), 0);
        if (result <= 0)
            break;

        for (i = 0; i < result; ++i) {
            if (buffer->writeIndex < sizeof(buffer->buffer))
                buffer->buffer[buffer->writeIndex++] = rot13_char(buf[i]);
            if (buf[i] == '\n') {
                buffer->readable = 1;  //緩衝區可以讀
            }
        }
    }

    if (result == 0) {
        return 1;
    } else if (result < 0) {
        if (errno == EAGAIN)
            return 0;
        return -1;
    }

    return 0;
}

int onSocketWrite(int fd, struct Buffer *buffer) {
    while (buffer->readIndex < buffer->writeIndex) {
        ssize_t result = send(fd, buffer->buffer + buffer->readIndex, buffer->writeIndex - buffer->readIndex, 0);
        if (result < 0) {
            if (errno == EAGAIN)
                return 0;
            return -1;
        }
        buffer->readIndex += result;
    }

    if (buffer->readIndex == buffer->writeIndex)
        buffer->readIndex = buffer->writeIndex = 0;

    buffer->readable = 0;
    return 0;
}

int main(int argc, char **argv) {
    int listenfd;
    int i, maxfd;

    struct Buffer *buffer[FD_INIT_SIZE];
    for (i = 0; i < FD_INIT_SIZE; ++i) {
        buffer[i] = alloc_Buffer();
    }

    listenfd = socket(AF_INET, SOCK_STREAM, 0);

    fcntl(listenfd, F_SETFL, O_NONBLOCK);

    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(port);

    int on = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

    int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
    if (rt1 < 0) {
        printf("bind failed \n");
        exit(0);
    }

    int rt2 = listen(listenfd, LISTENQ);
    if (rt2 < 0) {
        printf("listen failed \n");
        exit(0);
    }

    signal(SIGPIPE, SIG_IGN);

    fd_set readset, writeset, exset;
    FD_ZERO(&readset);
    FD_ZERO(&writeset);
    FD_ZERO(&exset);

    while (1) {
        maxfd = listen_fd;

        FD_ZERO(&readset);
        FD_ZERO(&writeset);
        FD_ZERO(&exset);

        // listener加入readset
        FD_SET(listen_fd, &readset);

        for (i = 0; i < FD_INIT_SIZE; ++i) {
            if (buffer[i]->connect_fd > 0) {
                if (buffer[i]->connect_fd > maxfd)
                    maxfd = buffer[i]->connect_fd;
                FD_SET(buffer[i]->connect_fd, &readset);
                if (buffer[i]->readable) {
                    FD_SET(buffer[i]->connect_fd, &writeset);
                }
            }
        }

        if (select(maxfd + 1, &readset, &writeset, &exset, NULL) < 0) {
            printf("select error\n");
            continue;
        }

        if (FD_ISSET(listen_fd, &readset)) {
            printf("listening socket readable\n");
            sleep(5);
            struct sockaddr_storage ss;
            socklen_t slen = sizeof(ss);
           int fd = accept(listen_fd, (struct sockaddr *) &ss, &slen);
            if (fd < 0) {
                printf("accept failed\n");
            } else if (fd > FD_INIT_SIZE) {
                printf("too many connections\n");
                close(fd);
            } else {
                fcntl(fd, F_SETFL, O_NONBLOCK);
                if (buffer[fd]->connect_fd == 0) {
                    buffer[fd]->connect_fd = fd;
                } else {
                    printf("too many connections\n");
                }
            }
        }

        for (i = 0; i < maxfd + 1; ++i) {
            int r = 0;
            if (i == listen_fd)
                continue;

            if (FD_ISSET(i, &readset)) {
                r = onSocketRead(i, buffer[i]);
            }
            if (r == 0 && FD_ISSET(i, &writeset)) {
                r = onSocketWrite(i, buffer[i]);
            }
            if (r) {
                buffer[i]->connect_fd = 0;
                close(i);
            }
        }
    }
}

總之, 非阻塞 I/O 可以使用在 read、write、accept、connect 等多種不同的場景,在非阻塞 I/O 下,使用輪詢的方式引起 CPU 佔用率高,所以一般將非阻塞 I/O 和 I/O 多路複用技術 select、poll 等搭配使用,在非阻塞 I/O 事件發生時,再調用對應事件的處理函數。這種方式,極大地提高了程序的健壯性和穩定性,是 Linux 下高性能網絡編程的首選。

 

溫故而知新 !

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章