从零到负一

Socket编程基础3 - IO多路复用基础

2020/04/25

这篇文章复习了UNP第6章,通过实例解决了第5章遗留下的一些问题。之前我们使用多进程来连接多个客户端,每一个客户端都需要一个新的进程,资源开销大。在这章,我们使用多路复用就可以不用这么多进程了。

I/O模型

我们可以将I/O模型分为5类:

  1. 阻塞式I/O
  2. 非阻塞式I/O
  3. I/O复用
  4. 信号驱动式I/O
  5. 异步I/O

根据是否阻请求进程,I/O又可以分为同步和异步。同步指的是请求进程不阻塞,异步指的是请求进程会被阻塞;只有异步I/O才是真正的异步,其他I/O模型都是同步的。图1是这几种I/O的区别:

图1. I/O模型的对比

关于这几种I/O的具体区别,请参考UNP的6.2章节。

多路复用简介

什么是多路复用?多路复用它的英文是Multiplexing,就是可以在多个中选一个。在Unix网络通信中,最常见的多路复用函数有3个 - select(), poll()epoll()select()poll()原理类似,epoll()和它们原理不同,在大部分时候epoll()效率最高。

select()函数

函数原型如下:

1
2
int select(int nfds, fd_set *restrict readfds, fd_set *restrict writefds, 
fd_set *restrict errorfds, struct timeval *restrict timeout);

nfds是需要测试的描述符个数,nfds可以测试描述符0到描述符nfds - 1nfds虽然是需要测试的描述符个数,但我们需要设置其为最大描述符 + 1。比如我们需要测试fd7, fd3fd20nfds并不是设置为3,而应该设置为21
readfds, writefdserrorfds是三个描述符集,描述符集表示了哪些描述符是我们需要测试的,对于需要测试的,我们给其置1,其他的保留0。一般我们用下面几个宏来处理描述符集:

1
2
3
4
void FD_CLR(int fd, fd_set *fdset); 
int FD_ISSET(int fd, fd_set *fdset);
void FD_SET(int fd, fd_set *fdset);
void FD_ZERO(fd_set *fdset);

一般情况下,一个描述符集最多支持1024个描述符,如果需要更多的描述符,就需要使用其他函数或者手动修改内核文件(不仅仅是简单地修改FD_SETSIZE)。对于不关心的描述符集,我们可以设置为NULL,当函数返回时,这几个描述符集会被更新,被设置了的为1,没有被设置的为0,因此我们需要保存之前描述符集的状态。
timeouttimeval类型的结构体,用于设置阻塞时间,如果超时了就返回0。我们可以设置一直等下去,等一段时间或者不等待。

描述符就绪状态

我这里直接摘录书中原文,下列情况下socket可读:

  1. socket内核接收缓存区中的字节数大于或等于其低水位标记SO_RCVLOWAT。此时可以无阻塞地读该socket,并且读操作返回的字节数大于0。
  2. socket的读这一半被关闭。此时对该socket读操作将返回0。也就是说这个时候socket一定可读,并且一定返回0
  3. 监听socket上有新的连接请求。
  4. socket上有未处理的错误。此时我们可以使用getsockopt()来读取和清除该错误。

下列情况下socket可写:

  1. socket内核发送缓冲区中的可用字节数大于或等于其低水位标记SO_SNDLOWAT。此时我们可以无阻塞写该socket,并且写操作返回的字节数大于0。
  2. socket的写这一半被关闭。对写操作被关闭的socket执行写操作将触发一个SIGPIPE信号。同样说明这个时候socket一定可以写
  3. socket使用非阻塞connect连接成功或者失败(超时)之后。
  4. socket上有未处理的错误。此时我们可以使用getsockopt()来读取和清除该错误。

改进后的str_cli()函数

我们这里用select()函数改进下上一章中的str_cli(),下面是函数源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// select/strcliselect01.c
#include "unp.h"


void
str_cli(FILE *fp, int sockfd)
{
int maxfdp1;
fd_set rset;
char sendline[MAXLINE], recvline[MAXLINE];
FD_ZERO(&rset);

for ( ; ; ) {
FD_SET(fileno(fp), &rset);
FD_SET(sockfd, &rset);
maxfdp1 = max(fileno(fp), sockfd) + 1;
Select(maxfdp1, &rset, NULL, NULL, NULL);

if (FD_ISSET(sockfd, &rset)) {
if (Readline(sockfd, recvline, MAXLINE) == 0)
err_quit("str_cli: server terminated prematurely");
Fputs(recvline, stdout);
}

if (FD_ISSET(fileno(fp), &rset)) {
if (Fgets(sendline, MAXLINE, fp) == NULL)
return; /* all done */
Writen(sockfd, sendline, strlen(sendline));
}
}
}

这个版本中,进程不会阻塞于stdin或者socket而是会阻塞于任何一个(不会出现一个被阻塞,另一个没法响应的情况)。因此不会出现服务器端已经发送FIN但客户端却阻塞在stdin的情况。

不过这个版本还有一个问题,如果客户端接收到EOF,那么str_cli()会返回,客户端主程序也会返回,那么进程就关闭了。但此时可能还有其他请求没有得到处理,图2展示了这种情况:

图2. socket上请求

比如请求8是EOF,此时请求5 - 7还没有被发送成功,应答1 - 4也还没有被客户端收到,但因为输入了EOF,那么Fgets()函数就会返回NULL从而导致客户端进程的结束。这么做会导致客户端不能再接收服务器端发送的信息,我们希望有一种方法可以使客户端不能发送但能接收信息,因此需要对上面代码再做修改。

再次修改的str_cli()函数

这里我们使用了shutdown()函数,而不是close()函数。close()只有当socket的计数器为0时才真正的关闭socket,但shutdown()函数立刻关闭socket。shutdown()函数可以选择关闭发送端还是接收端,如果是关闭发送端,那么socket还会发送一个FIN到连接的另一边。这样socket就不能写了,但可以读。

当shutdown发送端时(SHUT_WR),socket缓冲区的数据会被发送出去;当shutdown接收端时(SHUT_RD),socket缓冲区数据会被丢弃。

下面我们看看修改后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// select/strcliselect02.c
#include "unp.h"

void
str_cli(FILE *fp, int sockfd)
{
int maxfdp1, stdineof;
fd_set rset;
char buf[MAXLINE];
int n;

stdineof = 0;
FD_ZERO(&rset);
for ( ; ; ) {
if (stdineof == 0)
FD_SET(fileno(fp), &rset);
FD_SET(sockfd, &rset);
maxfdp1 = max(fileno(fp), sockfd) + 1;
Select(maxfdp1, &rset, NULL, NULL, NULL);

if (FD_ISSET(sockfd, &rset)) {
if ((n = Read(sockfd, buf, MAXLINE)) == 0) {
if (stdineof == 1)
return;
else
err_quit("str_cli: server terminated prematurely");
}

Write(fileno(stdout), buf, n);
}

if (FD_ISSET(fileno(fp), &rset)) {
if ((n = Read(fileno(fp), buf, MAXLINE)) == 0) {
stdineof = 1;
Shutdown(sockfd, SHUT_WR); /* send FIN */
FD_CLR(fileno(fp), &rset);
continue;
}

Writen(sockfd, buf, n);
}
}
}

修改后的代码,当接收到stdinEOF后,客户端将关闭发送端,但接收端不受影响,因此可以继续接收服务器端的信息。直到服务器端关掉相关socket后,Read函数收到FIN并返回0,这样客户端进程才会被关闭。至于服务器端的最终代码,请参考UNP 6.8节。

poll()和epoll()函数

UNP只介绍了poll()函数,并没有介绍epoll()函数。这两个函数都很重要,因此我准备单独写一篇文章来讲讲这两个函数以及和select()的对比。

CATALOG
  1. 1. I/O模型
  2. 2. 多路复用简介
    1. 2.1. select()函数
      1. 2.1.1. 描述符就绪状态
      2. 2.1.2. 改进后的str_cli()函数
      3. 2.1.3. 再次修改的str_cli()函数
    2. 2.2. poll()和epoll()函数