网络io基础

socket被定义为在网络通信中用来收发数据的端点,在c中可以通过 int socket(int domain, int type, int protocol)来创建,所返回的文件描述符(file descriptor)会是该进程未被占用的最小数字的文件描述符,比如一个进程新创建是会占用0,1,2三个文件描述符分别代表标准输入,标准输出和标准错误,若未进行额外操作,那么使用socket 函数返回的int应该为3。

socket包含接收缓冲区和发送缓冲区,读取时数据会从接收缓冲区读取到用户态的缓冲区,写入时数据会先从用户态的缓冲区拷贝到发送缓冲区中

网络数据接收时发生了什么

  1. 数据首先先到达网卡(NIC: network interface card)
  2. 内核根据数据的ip和port来确定连接,并将数据从网卡的buffer拷贝到相应socket的接收缓冲区中
  3. 等待该socket读取事件发生的进程(比如说调用了read的进程)会被唤醒,等待os的调度
  4. 在进程执行read时,相应的数据会从socket的接收缓冲区拷贝到用户(由read指定的地址)的buffer中

网络io整体框架

阻塞io(blocking io) 和 非阻塞io(nonblocking io)

阻塞io 意味着对io的操作会阻塞,而非阻塞io对于io进行操作时会立即返回

read举例,read的函数签名是ssize_t read(int fd, void *buf, size_t count)。如果fd为阻塞模式时,如果此时fd的缓冲区没有数据,则会阻塞,直到有数据可读;如果fd为非阻塞模式,如果fd缓冲区没有数据,则会返回-1,并且errno会变成 EAGAIN或者 EWOULDBLOCK,来表明当前没有数据可以读取。

读取的示例代码如下,socketpair会生成一对socket连接,将fd[1]设为阻塞模式,写入fd[0]并从fd[1]读取时,可以正常返回;

如果将write(fd[0], write_buffer, len)删除,即fd[1]端没有可以读取的数据,当fd[1]是阻塞模式时,则程序会在int p = read(fd[1], read_buffer, len)阻塞住;当为非阻塞模式时,程序不会被block,并且errno会被设置成35(EAGAIN)

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

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

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

int main() {
    int fd[2];
    socketpair(PF_LOCAL, SOCK_STREAM, 0, fd); // 生成socket pair

    int flags = fcntl(fd[1], F_GETFL, 0);
    fcntl(fd[1], F_SETFL, flags & ~O_NONBLOCK); // 设置为阻塞模式
    // fcntl(fd[1], F_SETFL, flags | O_NONBLOCK); // 设置为非阻塞模式

    int len = 10;
    char *write_buffer = "123456789";
    char read_buffer[len];
    write(fd[0], write_buffer, len);

    int p = read(fd[1], read_buffer, len);
    printf("read %d\n", p);
    printf("read %s\n", read_buffer);
    printf("err is %d\n", errno);
}

select / epoll / kqueue

select/epoll/kqueue:监控多个fd是否就绪,会被一直阻塞到其中一个或一些fd已经ready*(可以进行io操作)。区别在于select性能较差,epoll/kqueue性能较好;epoll在linux系统支持,kqueue在osx/freebsd上支持,而select全平台支持,通常作为备选的方式。

fd的ready指的是什么*

read的fd就绪指socket的接收缓冲区里有数据可以被读取,即此时调用read方法会立即得到数据,不会被阻塞;

write的fd就绪指socket的发送缓冲区不是满的,即可以使用write方法将数据写入发送缓冲区,不会被阻塞;

select

select函数签名为int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout),一般的使用简化如下:

int sock = init_sock(); // 生成socket
fd_set read_sets;
FD_ZERO (&read_sets);
FD_SET (sock, &read_sets);  // 将sock注册到read_sets中

while (1) {
    fd_set read_fd_set = read_sets; // 每次select返回时,均会修改read_fd_set为已经ready的fd,所以每次while时均需要恢复原状态
    select (FD_SETSIZE, &read_fd_set, NULL, NULL, NULL) < 0); // 监控read_fd_set,阻塞至read_fd_set中有fd就绪
    for (int i = 0; i < FD_SETSIZE; ++i) { // 循环所有fd,来验证哪些fd就绪了
        if (FD_ISSET (i, &read_fd_set)) { // 如果当前fd就绪了,则进行操作
            // do something
        }
    }
}

这里可以看出,每次select返回时,需要遍历所有fd才能找出所有已经就绪的fd;而由于返回时,fd_set会被select设置为已经就绪的fd列表,所以每次select时,均需要将fd_set恢复原状,这会涉及到fd_set从用户态和内核态的来回拷贝。这也就造成了select函数的效率相对低下。

epoll

epoll相关函数有epoll_create(int size)int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout),一般使用如下:

int epfd = epoll_create(1);
epoll_ctl(epfd, ..., ..., &event);
events = calloc(MAXEVENTS, sizeof event);
while (1) {
    int n = epoll_wait(epfd, events, MAXEVENTS, -1);
    for (int i = 0; i < n; i++) {
            event = events[i];
            // do something
    }
}

相比较select而言,可以看到epoll将监控哪些fd(epoll_ctl)以及具体等待动作(epoll_wait)解耦,因此epoll只需要在最开始的时候配置fd,而不需要每次循环都去重新配置;同时,epoll_wait返回的events即为发生的io事件,因此与select相比不需要重新遍历所有fd。

epoll的水平触发/边缘触发

以read为例,如果此时有数据到达socket,此时epoll_wait会返回,后续的read读取该socket的接收buffer里的数据,如果此时没有读取完,那么下次再次调用epoll_wait时:

epoll的水平触发:epoll_wait会立即返回,通知该socket还有数据可读。总体来说,就是epoll_wait是否返回取决于socket的缓冲区buffer是否有数据

epoll的边缘触发:epoll_wait会block,即使socket的buffer中还有数据,一直会等到该fd再次有可读事件发生

下面代码是通过epoll读取stdin的例子,将read的buffer设置为了1byte,即每次调用read时只会从缓冲区中读取1byte。运行代码输入1234时,可以发现while循环共进行了4次,分别输出1/2/3/4,意味着epoll_wait在水平触发并且socket的接收缓冲区还有数据时,是不会阻塞的;而如果设置为边缘触发,则会发现while循环只会进行1次,意味着epoll_wait在边缘触发并且socket的接收缓冲区还有数据时,还是会阻塞,一直等到下次事件发生。

#include <sys/epoll.h>

#include <unistd.h>
#include <stdio.h>

#define MAX_EVENTS 5

int main() {
    int epoll_fd = epoll_create1(0);
    struct epoll_event event;
    event.events = EPOLLIN; // 默认为水平触发
    // event.events = EPOLLIN | EPOLLET; // 设置为边缘触发
    event.data.fd = 0;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, 0, &event);
    struct epoll_event events[MAX_EVENTS];
    int read_size = 1;
    char read_buffer[read_size];
    int i = 0;

    while(1) {
        int event_count = epoll_wait(epoll_fd, events, MAX_EVENTS, 30000);
        printf("%d ready events\n", event_count);
        for(i = 0; i < event_count; i++) {
            printf("Reading file descriptor '%d'", events[i].data.fd);
            int bytes_read = read(events[i].data.fd, read_buffer, read_size);
            read_buffer[bytes_read] = '\0';
            printf("Read '%s'\n", read_buffer);
        }
    }
}

为什么边缘触发的epoll,fd需要set为nonblocking

从上面的分析可以看出,epoll配置为边缘触发时,如果接收缓冲区还有残留数据时,下次的epoll_wait是不会接着返回的,而是接着等待下次事件才会返回。所以在epoll为边缘触发时,读取时通常会使用循环来去确保接收缓冲区的数据完全被读取,再接着进行epoll_wait操作。如果fd是block的话,则无法使用循环,因为read会读取完缓冲区里的数据后hang住,没法接着回到epoll_wait的逻辑;而fd是non blocking的话,可以用循环一直读取到read返回 EAGAIN 代表此时缓冲区已经为空,此时程序就可以接着epoll_wait而不用担心缓冲区还有数据的情况了。

参考文档

https://www.cnblogs.com/Hijack-you/p/13057792.html
https://stackoverflow.com/questions/5351994/will-read-ever-block-after-select
https://eklitzke.org/blocking-io-nonblocking-io-and-epoll
https://eklitzke.org/how-tcp-sockets-work