tcp使用的进一步了解

以前对socket的了解仅仅局限于listen/connect/epoll/select/close等这些API的表面使用。其具体语义以及一些状态都没有深究。总觉得这样写代码会出问题,今天咬咬牙把《tcp协议卷1》中的Tcp部分又看了一遍。发现由于对协议和API语义的了解不足,在程序中还是犯了不少错误。

TIME_WAIT状态是tcp网络编程中最不容易理解的地方。主动关闭的一方会经历TIME_WAIT状态, 这个状态会经历2MSL的时间。其目的就是为了可靠的实现TCP全双工连接的终止和允许老的重复分组消逝在网络中(具体可以参看UNP P37)。

TIME_WAIT的持续时间一般是1分钟到4分钟,如果在这段时间内涌入大量连接,然后服务器将其断开,就会导致在服务器上残留大量处理TIME_WAIT的连接,理论上这会严重拖慢系统的性能。

因此,只要条件允许,应该尽量让客户端来主动断开连接。

由于服务器绑定的是固定端口,当重启服务器时,只要还存在有通过这个固定端口接入进来的TIME_WAIT状态的连接,就会导致bind失败。因此服务器一定要为bind的socket设置SO_REUSEADDR属性。


close的默认行为首先对描述符引用计数减一,如果引用计数为为0则执行close流程。即把该socket标记为关闭, 然后立即返回到调用进程,该socket不能再被调用进程使用。然而tcp将尝试发送已经排队等待发送到对端的任何数据,发送完闭后才会执行正常的tcp连接终止序列(即四次挥手). 这个行为可以通过SO_LINGER更改。

相比close, shutdown的行为则更为粗爆一些。

不管有多少进程在持有这个socket,一旦这个socket被shutdown, 那么所有的进程均不能再对此socket进行已经shutdown过的操作。shutdown可以分别关闭读和写。

shutdown写时会将当前发送缓冲区中的数据都发送掉才会进行连接终止序列。
shutdown读时会将当前接收缓冲区中的数据都请空。

在silly的实现中,我对于close的实现仅仅是粗爆的将所有发送缓冲区清空,然后关闭socket。对比系统的close和shutdown函数可以发现, 这样做是不对的,因为在应用层调用close时,有可能数据并没有发完, 这样就会有可能导致客户端接收到的信息不完成,而造成其他bug.

Silly多端口监听支持

前两天在编写一个新服务器程序时才意识到,多台不同类型的服务器相互通信,服务器仅监听一个端口号是非常不方便的,比如服务器集群中的ctrl_server需要接受多种不同的服务器建立链接,让所有链接去涌入同一个端口显然是不明智的,这时候就需要监听多个不同的端口。

让我纠结了两天的问题是到底该以怎么样的方式增加多端口支持。

首先先到的是,去掉配置文件中port的支持,增加一个listen API让lua逻辑自己去指定监听哪几个端口号,但是有一个明显的问题,多个worker现在启动时加载同一套lua代码,一个worker监听成功就会导致,其他worker失败。

现在面临着两个旋择,一个就是让配置文件可以没个worker指定一个bootstrap,但这样会有一个问题,多个worker并不能通信,有可能两个端口的命令需要操作一块共享的数据,因此并不能实现。

还有一个让lua逻辑判断只有workid为零时才真的去监听,其他worker仅仅注册一下相关端口的处理函数即可。但总感觉这种设计有点bad taste的感觉。

还有一种方式就是让silly底层可以支持对一个端口多次监听,但如果lua逻辑是在刚启动就进行端口监听(大多数情况也的确如此),那么多个worker同时调用listen api时就要处理并发问题,这样事情就稍微变得复杂一点了。

为了简化设计,我修改了为lua提供的listen API语义,此api仅仅向socket注册不同端口号的处理函数并不实际进行监听操作,silly根据配置文件中配置的端口号组进行监听,当从某个端口号的listen fd accept出socket向socket.lua发消息时,将这个socket来源于哪个监听端口号一并发出。

这样socket.lua就可以根据端口号来为这个socket fd选择合适的处理函数。

为了避免lua中listen的端口号和配置文件指定的端口号不同的错误,silly的config文件在指定端口号组时需要为每个端口号起一个名字,在lua代码中listen传入的参数是port的名字,而并非是数字。


11月4日补充:

如上文中的ctrl_server即需要接受client请求又需要与其他server进行通信。

client与server之间的通信是不被信任的,所以所有的通信数据包需要进行加密处理,而server与server之间的通信是可以相互信任的,所以都是明文发送。因此一般的服务器集架设过程中会为每台服务器配置2块网卡,一块网卡接入公网为client服务器,另一块网卡用于与其他服务器组成一个局域网。

这样做可以不但可以增加server之间通信的安全性,还可以提高server之间通信的效率。

仔细回忆一两台计算机通过网络通信的流程。1台电脑发出数据包后, 数据包首先到达网关,网关根据去目的ip找到相应的计算机,然后将数据包转到给相应的计算机。

如果server之间使用公网ip进行通信,那么就必须要经过电信的网关,这样数据包不但会有被拦截的风险,还会增加不必要的开销。毕竟相比局域网的网关来说,电信的网关实在太远了。

为了防止有恶意链接从公网接入开放给其他服务器进行通信的接口, 在listen时必须能够支持仅对某块网卡上的特定端口进行监听。

因此silly除了要增加多端口监听支持外, 还需要增加对指针ip地址的某端口进行监听。当我们需要针对任何网卡进行监听时,将ip地址设为0.0.0.0即可表示INADDR_ANY.

tcp的close和shutdown

今天看到”Unix网络编程”第六章中半关闭连接, 对close和shutdown的功能区别产生了疑惑. 代码运行情况如下:

情况1:C(client)与S(server)建立链接之后, 当C向S发送数据之后调用shutdown来关闭写操作(断开链接的四次挥手中的前两次)告诉S, C端已发送数据完成, 此时S依然可向C发送数据.
情况2:C(client)与S(server)建议链接之后, 当C向S发送数据之后调用close来关闭socket(同样发送断开链接的四次挥手的前两次, 后两次挥手将由S端调用close来完成), 此时S端被其他条件阻赛并不调用close函数. 然后此时S端向C端发送数据将会引起C端回应rst数据包.

那么此时,情况1与情况2的socket状态机走到相同的地方, 所作的回应却完全不一样, 行为很令人奇怪.

在网上看了众多blog多后, 比较发现, close除了相当于C和S各发了shutdown之外, 还多了一个释放socket file descriptor的操作. 因此,猜测当调用close之后函数应该是瞬间返回的, 剩余的四次挥手以及TIME_WAIT状态应该是靠kernel来实现的, 那么就容易解释上述情况的原因了.

情况1:虽然调用了shutdown, 但是C依然拥有这个socket file descriptor, 因此算是半关闭链接, 情况2在调用close之后, C所拥有的socket file desciptor即被归还与kernel, 那么剩余的四次挥手及TIME_WAIT即由kernel来完成, 当此时S向C发送数据之后, kernel发现此socket file descriptor已经被释放, 即向S返回rst数据包.

以上均为猜测, 如找到相关文档再进行修正.

tcp的端口号

虽然写了一年多的网络编程, 但是总觉得有种浮沙筑高台的感觉,于是去买了本unix网络编程卷一去读. 果不其然看到第五章使用netstat去调试第一个程序时就产生了一个疑惑.

这个例子每accept一个连接就去fork一个进程去处理, 当我开了一个server和2个client时发现, server一共会有三个进程存在, 一个是listen, 另外两个是accpet出来的, 但是使用netstat去调试时却发现这三个进程其实共享server进程的bind的端口,以前只是知道”端口是用来标识同一个IP的不同进程之间数据收发”, 却从没有深究其中原理. 而之前在windows写socket程序一般都是多线程, 多进程的方案从来没用过, 所以从未觉得这句话有什么不妥.

可能这个问题太简单了,google了半天都没找到有人解答, 最终在tcp/ip详解卷一的第18章P194上找到这样一句话”tcp使用由本机地址和远端地址组成的4元组:目的IP地址, 目的端口号, 源IP地址和源端口号来处于是传入的多个链接请求”, 于是一下豁然开朗, 找到tcp的协议头翻了一下, 果然其实每个tcp segment都是有这个四元素的. 那其实就问题就很明显了, 根本不是进程与端口的问题, 其实应该是说”端口和IP地址(源端口, 目的端口, 源IP和目的IP)是用来标识不同socket之间的数据收发”.

再仔细回忆一下server端的完整步骤:
socket //获取一个socket descriptor
bind //将刚获取socket descriptor绑定相应的地址或port(一般地址为INADDR_ANY)
listen //向kernel标识接收向该socket上的连接请求
while accept //接收client的请求


大致猜测一下kernel的方式, 当收到tcp segment时首先去查看tcp segment中的目的端口并找到与目的端口相关的socket, 然后如果发现SYN此类与连接有关的segment的时候直接转发给被listen的那个socket, 否则就根据tcp segment的源端口号和源IP地址去匹配相应的socket进行数据收发.