基于事件的非阻塞网络编程是编写高性能并发网络服务程序的主流模式,头一次使用这种方式编程通常需要转换思维模式。把原来“主动调用recv(2) 来接收数据,主动调用accept(2) 来接受新连接,主动调用send(2) 来发送数据”的思路换成“注册一个收数据的回调,网络库收到数据会调用我,直接把数据提供给我,供我消费。注册一个接受连接的回调,网络库接受了新连接会回调我,直接把新的连接对象传给我,供我使用。需要发送数据的时候,只管往连接中写,网络库会负责无阻塞地发送。”这种编程方式有点像Win32 的消息循环,消息循环中的代码应该避免阻塞,否则会让整个窗口失去响应,同理,事件处理函数也应该避免阻塞,否则会让网络服务失去响应。
我认为,TCP 网络编程最本质的是处理三个半事件:
1. 连接的建立,包括服务端接受(accept)新连接和客户端成功发起(connect)连接。TCP 连接一旦建立,客户端和服务端是平等的,可以各自收发数据。
2. 连接的断开,包括主动断开(close、shutdown)和被动断开(read(2) 返回0)。
3. 消息到达,文件描述符可读。这是最为重要的一个事件,对它的处理方式决定了网络编程的风格(阻塞还是非阻塞,如何处理分包,应用层的缓冲如何设计,等等)。
3.5 消息发送完毕,这算半个。对于低流量的服务,可以不必关心这个事件;另外,这里的“发送完毕”是指将数据写入操作系统的缓冲区,将由TCP 协议栈负责数据的发送与重传,不代表对方已经收到数据。
这其中有很多难点,也有很多细节需要注意,比方说:
如果要主动关闭连接,如何保证对方已经收到全部数据?如果应用层有缓冲(这在非阻塞网络编程中是必需的,见下文),那么如何保证先发送完缓冲区中的数据,然后再断开连接?直接调用close(2) 恐怕是不行的。
如果主动发起连接,但是对方主动拒绝,如何定期(带back-off 地)重试?
非阻塞网络编程该用边沿触发(edge trigger)还是电平触发(level trigger)?如果是电平触发,那么什么时候关注EPOLLOUT 事件?会不会造成busy-loop?如果是边沿触发,如何防止漏读造成的饥饿?epoll(4) 一定比poll(2) 快吗?
在非阻塞网络编程中,为什么要使用应用层发送缓冲区?假设应用程序需要发送40kB 数据,但是操作系统的TCP 发送缓冲区只有25kB 剩余空间,那么剩下的15kB数据怎么办?如果等待OS 缓冲区可用,会阻塞当前线程,因为不知道对方什么时候收到并读取数据。因此网络库应该把这15kB 数据缓存起来,放到这个TCP 链接的应用层发送缓冲区中,等socket 变得可写的时候立刻发送数据,这样“发送”操作不会阻塞。如果应用程序随后又要发送50kB 数据,而此时发送缓冲区中尚有未发送的数据(若干kB),那么网络库应该将这50kB 数据追加到发送缓冲区的末尾,而不能立刻尝试write(),因为这样有可能打乱数据的顺序。
在非阻塞网络编程中,为什么要使用应用层接收缓冲区?假如一次读到的数据不够一个完整的数据包,那么这些已经读到的数据是不是应该先暂存在某个地方,等剩余的数据收到之后再一并处理?见lighttpd 关于\r\n\r\n 分包的bug 13。假如数据是一个字节一个字节地到达,间隔10ms,每个字节触发一次文件描述符可读(readable)事件,程序是否还能正常工作?lighttpd 在这个问题上出过安全漏洞。
在非阻塞网络编程中,如何设计并使用缓冲区?一方面我们希望减少系统调用,一次读的数据越多越划算,那么似乎应该准备一个大的缓冲区。另一方面,我们希望减少内存占用。如果有10 000 个并发连接,每个连接一建立就分配各50kB 的读写缓冲区(s) 的话,将占用1GB 内存,而大多数时候这些缓冲区的使用率很低。muduo用readv(2) 结合栈上空间巧妙地解决了这个问题。
如果使用发送缓冲区,万一接收方处理缓慢,数据会不会一直堆积在发送方,造成内存暴涨?如何做应用层的流量控制?
如何设计并实现定时器?并使之与网络IO 共用一个线程,以避免锁。这些问题在muduo 的代码中可以找到答案。
[ 此帖被在2014-10-11 17:39重新编辑 ]