这篇文章复习了UNP第6章,通过实例解决了第5章遗留下的一些问题。之前我们使用多进程来连接多个客户端,每一个客户端都需要一个新的进程,资源开销大。在这章,我们使用多路复用就可以不用这么多进程了。
I/O模型
我们可以将I/O模型分为5类:
- 阻塞式I/O
- 非阻塞式I/O
- I/O复用
- 信号驱动式I/O
- 异步I/O
根据是否阻请求进程,I/O又可以分为同步和异步。同步指的是请求进程不阻塞,异步指的是请求进程会被阻塞;只有异步I/O才是真正的异步,其他I/O模型都是同步的。图1是这几种I/O的区别:
关于这几种I/O的具体区别,请参考UNP的6.2章节。
多路复用简介
什么是多路复用?多路复用它的英文是Multiplexing
,就是可以在多个中选一个。在Unix网络通信中,最常见的多路复用函数有3个 - select()
, poll()
和epoll()
。select()
和poll()
原理类似,epoll()
和它们原理不同,在大部分时候epoll()
效率最高。
select()函数
函数原型如下:
1 | int select(int nfds, fd_set *restrict readfds, fd_set *restrict writefds, |
nfds
是需要测试的描述符个数,nfds
可以测试描述符0
到描述符nfds - 1
;nfds
虽然是需要测试的描述符个数,但我们需要设置其为最大描述符 + 1
。比如我们需要测试fd7
, fd3
和fd20
,nfds
并不是设置为3,而应该设置为21
。readfds
, writefds
和errorfds
是三个描述符集,描述符集表示了哪些描述符是我们需要测试的,对于需要测试的,我们给其置1
,其他的保留0
。一般我们用下面几个宏来处理描述符集:
1 | void FD_CLR(int fd, fd_set *fdset); |
一般情况下,一个描述符集最多支持1024个描述符,如果需要更多的描述符,就需要使用其他函数或者手动修改内核文件(不仅仅是简单地修改FD_SETSIZE
)。对于不关心的描述符集,我们可以设置为NULL
,当函数返回时,这几个描述符集会被更新,被设置了的为1
,没有被设置的为0
,因此我们需要保存之前描述符集的状态。timeout
是timeval
类型的结构体,用于设置阻塞时间,如果超时了就返回0
。我们可以设置一直等下去,等一段时间或者不等待。
描述符就绪状态
我这里直接摘录书中原文,下列情况下socket可读:
- socket内核接收缓存区中的字节数大于或等于其低水位标记SO_RCVLOWAT。此时可以无阻塞地读该socket,并且读操作返回的字节数大于0。
- socket的读这一半被关闭。此时对该socket读操作将返回0。
也就是说这个时候socket一定可读,并且一定返回0
。 - 监听socket上有新的连接请求。
- socket上有未处理的错误。此时我们可以使用
getsockopt()
来读取和清除该错误。
下列情况下socket可写:
- socket内核发送缓冲区中的可用字节数大于或等于其低水位标记
SO_SNDLOWAT
。此时我们可以无阻塞写该socket,并且写操作返回的字节数大于0。 - socket的写这一半被关闭。对写操作被关闭的socket执行写操作将触发一个SIGPIPE信号。
同样说明这个时候socket一定可以写
。 - socket使用非阻塞connect连接成功或者失败(超时)之后。
- socket上有未处理的错误。此时我们可以使用
getsockopt()
来读取和清除该错误。
改进后的str_cli()函数
我们这里用select()
函数改进下上一章中的str_cli()
,下面是函数源代码:
1 | // select/strcliselect01.c |
这个版本中,进程不会阻塞于stdin
或者socket
而是会阻塞于任何一个(不会出现一个被阻塞,另一个没法响应的情况)。因此不会出现服务器端已经发送FIN
但客户端却阻塞在stdin
的情况。
不过这个版本还有一个问题,如果客户端接收到EOF
,那么str_cli()
会返回,客户端主程序也会返回,那么进程就关闭了。但此时可能还有其他请求没有得到处理,图2展示了这种情况:
比如请求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 | // select/strcliselect02.c |
修改后的代码,当接收到stdin
的EOF
后,客户端将关闭发送端,但接收端不受影响,因此可以继续接收服务器端的信息。直到服务器端关掉相关socket后,Read
函数收到FIN
并返回0,这样客户端进程才会被关闭。至于服务器端的最终代码,请参考UNP 6.8节。
poll()和epoll()函数
UNP只介绍了poll()
函数,并没有介绍epoll()
函数。这两个函数都很重要,因此我准备单独写一篇文章来讲讲这两个函数以及和select()
的对比。