目录
网络io基础
socket被定义为在网络通信中用来收发数据的端点,在c中可以通过 int socket(int domain, int type, int protocol)
来创建,所返回的文件描述符(file descriptor)会是该进程未被占用的最小数字的文件描述符,比如一个进程新创建是会占用0,1,2三个文件描述符分别代表标准输入,标准输出和标准错误,若未进行额外操作,那么使用socket
函数返回的int应该为3。
socket包含接收缓冲区和发送缓冲区,读取时数据会从接收缓冲区读取到用户态的缓冲区,写入时数据会先从用户态的缓冲区拷贝到发送缓冲区中
网络数据接收时发生了什么
- 数据首先先到达网卡(NIC: network interface card)
- 内核根据数据的ip和port来确定连接,并将数据从网卡的buffer拷贝到相应socket的接收缓冲区中
- 等待该socket读取事件发生的进程(比如说调用了read的进程)会被唤醒,等待os的调度
- 在进程执行read时,相应的数据会从socket的接收缓冲区拷贝到用户(由read指定的地址)的buffer中
阻塞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
Comments | NOTHING