TCP复习笔记(三) TCP套接字

主要流程

服务器端套接字的主要流程:

  1. socket():创建一个主动套接字
  2. bind():为套接字绑定一个本地协议地址和端口(这一步不是必须)
  3. listen():将套接字改为被动套接字,如果套接字没有绑定端口,为套接字选择一个临时端口,此时TCP状态机等待SYN包的到达
  4. accept():从listen backlog队列中取出一个已经建立(已完成三次握手)的连接

而对于客户端来说,只需要知道服务器IP,Port,直接connect()即可,客户端一般无需主动调用bind()绑定端口,因为客户端不关心它的本地端口,connect()会为其选择一个临时端口。

套接字API

socket()

创建一个指定协议簇和套接字类型套接字,对于IPv4 TCP套接字,通常创建方式为socket(AF_INET, SOCKET_STREAM, 0),该函数创建的TCP套接字默认是主动套接字。

bind()

为指定套接字分配一个本地协议地址,该地址根据套接字类型而定。对于TCP套接字来说,调用bind()函数可以为套接字指定一个IP地址和本地端口,IP地址必须是本机的一个接口。

对于服务器套接字来说,绑定一个固定端口一般是必要的,因为客户端需要指定这个端口才能找到对应服务器进程。而对于IP地址,通常服务器可能不止一个IP,而绑定了一个固定IP意味着套接字只能接收那些目的地为此IP的连接。因此一般我们指定绑定地址为INADDR_ANY,意味着让内核来选择IP地址,内核的做法是:将客户端SYN包的目的IP地址,作为服务器的源IP地址。

如前面所说,客户端一般是不需要调用bind函数的,在调用connect()时,由内核选定一个本地IP地址和临时端口。

listen()

通过socket()函数创建的套接字为主动套接字,调用listen()将使该套接字变为被动套接字,这意味着内核应接收该套接字上的连接请求(SYN包),listen()使套接字从CLOSED状态变为LISTEN状态。

由于TCP三次握手,客户端与服务器建立连接需要两步:

  1. 在客户端SYN包到达时,服务端回复SYN-ACK包,此时套接字处于SYN_RECV状态
  2. 客户端ACK包到达,此时服务器套接字从SYN_RECV变为ESTABLISH状态,之后等待被accept()取出

因此,针对这两种状态的套接字的管理,有两种方案:

  1. 维护一个队列,里面包括SYN_RECV和ESTABLISH两种状态的套接字,当客户端三次握手最后一个ACK到达时,将对应套接字状态由SYN_RECV改为ESTABLISH。而accept()只会取出ESTABLISH状态的套接字。在这种实现中,listen()的backlog参数就是这个队列的最大长度。
  2. 维护两个队列,一个未完成连接队列(SYN队列),存放SYN_RECV状态的套接字。一个已完成连接队列(Accept队列),存放ESTABLISH状态的套接字。当连接完成(收到客户端的三次握手ACK)后,套接字将从SYN队列移到Accept队列尾部。accept()函数每次从已完成连接队列头部取出一个套接字。这种实现中,backlog参数指的是Accept队列最大长度。

历史上两种方案均被不同的套接字实现版本采取过,而目前Linux2.2以上的版本使用的是第二种方案(参见Linux listen() man page),意味着backlog限制Accept队列的大小,而SYN队列的大小通过tcp_max_syn_backlog内核参数来控制。那么这里我们有几个问题需要讨论:

  • 当SYN队列满时,新客户端再次尝试连接(发送SYN包),会发生什么?
  • 当Accept队列满时,收到了客户端的握手ACK,需要将套接字从SYN队列移至Accept队列,会发生什么?
  • 客户端发完握手ACK后,对客户端来说,连接已经建立(处于ESTABLISH状态)了,而服务器套接字由于各种原因(如Accept队列满)并未到达ESTABLISH状态,此时客户端向服务器发送数据,会发送什么?

当SYN队列满时,通常的TCP实现会忽略SYN包(而不是发送RST包重置连接),这使得客户端connect()会进行超时重传,等待SYN队列有空闲位置。tcp_syn_retries参数可以控制客户端SYN报文的重试次数。

当Accept队列满时,这通常是由于accept()调用处理不过来,如果这时收到了客户端的握手ACK包,如果内核参数tcp_abort_on_overflow=0,也就是默认情况,Linux实现会忽略该ACK,这将导致服务器会超时重传SYN-ACK包(参数tcp_synack_retries可控制重传次数),然后客户端收到SYN-ACK包,也会假设之前的ACK包丢失了,仍然会回复ACK,此时服务器再次收到ACK,可能Accept队列就有空闲位置了。而如果tcp_abort_on_overflow=1,服务器在Accept队列满了,处理不过来时,将直接回复一个RST包,这将导致一个客户端connect()错误: ECONNREFUSED。客户端将不会再次重试。在Linux下,当Accept队列满时,内核还会限制SYN包的进入速度,如果太快,有些SYN包将会被丢弃。

站在客户端的角度来说,当它收到SYN-ACK并回复ACK后,连接就已经建立了。此时如果它立即向服务器发送数据,而服务可能由于Accept队列满,忽略了ACK,也就仍然处于SYN_RECV状态,此时客户端发送的数据仍然将被忽略,并且由客户端重传。TCP的慢启动机制确保了连接刚建立时不会发送太多的数据。

最后,在Linux下,backlog指定的大小受限于/proc/sys/net/core/somaxconn。另外,不要将backlog设为0,不能的实现可能对此有不同的解释。

关于listen() backlog更详细的讨论参见:http://veithen.github.io/2014/01/01/how-tcp-backlog-works-in-linux.html

accept()

从Accept队列中取出一个已完成连接,若Accept队列为空,则进程睡眠(假设为阻塞方式)。

accept()返回一个新的连接套接字(内核已经为它已经完成三次握手),之后与客户端套接字的通信均通过该连接套接字来完成。

connect()

向指定地址发送SYN报文,尝试建立连接。如果套接字之前没有调用bind()绑定地址端口,内核会选择源IP地址和一个临时端口。

connect()仅在连接成功或出错时才返回,出错的可能有:

  • 没有收到SYN包的响应,尝试超时重发,最后仍无响应。返回ETIMEOUT
  • 如果收到的SYN响应为RST,表明服务器对应端口还没有进程监听(未调用listen并处于LISTEN状态,状态机不能接收SYN报文),客户端收到RST包立即返回ECONNREFUSED错误
  • 如果客户端发出的SYN引发了目的地不可达的ICMP错误,那么将按第一种情况重试,重试未果最终返回EHOSTUNREACH或ENETUNREACH