简介
TCP网络编程,通常是使用Posix API来操作socket来实现网络编程,而Posix API在window和linux下主要使用的接口只有以下几个:
- socket: 创建一个socket;
- bind: 绑定;
- listen: 监听;
- accept: 创建新的连接;
- recv: 接受数据;
- send: 发送数据;
- close: 关闭socket;
- connect: 连接;
另外setsockopt()/getsockopt()设置和获取属性的. 不管网络编程如何封装,如何设计,最终都是使用以上几个API实现; 但是这些API内部具体干了什么事呢?
socket
在Linux下的使用如下代码创建一个socket
int fd = socket() // fd = 3
如果在当前进程下第一次调用socket,那么fd肯定是等于3,0,1,2分别被stdin,stdout,stderr占用; 至于为什么占有,则需要从socket是什么来解释.
socket翻译为插座,本身也是有两部分构成。一部分为fd(文件描述符)和tcb(tcp control block)。fd为文件系统部分,而tcb为系统内核层面用户看不到的部分,主要为内核协议栈部分。操作fd实际操作的tcp协议栈部分, fd与tcb是一对一的。在进程启动的时候会创建三个fd,也就是stdin, stdout, stderr所以第一次创建socket的fd等于三,之后每次创建新的socket都是在之前的fd上加1,而fd其实就是一个内核中的记录标识而已。
bind
socket创建出来的tcb是一个closed状态(tcp 11个状态迁移),需要bind确定从什么地方接收和发送,俗称5元组, 包含remote ip, remote port, local ip, local port, protocol。绑定完后知道具体的连接后数据来源和去向。bind的端口,在接收或者发送数据,用来填充本机的ip和端口。当bind绑定0.0.0.0:8080的时候调用send时remote_ip是在优先选eth0。该函数大部分是在服务端使用,但是当客户端电脑有多个网卡或IP的时候,也可以使用该接口绑定具体的地址;
accept
从全连接队列中取出一个tcb, 然后创建一个fd,将tcb和fd建立关联,并返回fd, 之后对fd的操作都是针对对应的tcb操作。至于什么是全连接队列,稍后描述.
listen
服务器启动监听指定的端口.
connect
连接指定的地址.
send
send看名字是发送数据,但其实send只把数据拷贝到tcb sendbuffer中,然后交由内核协议栈来完成发送。而内核协议栈这个发送过程是非常复杂的。
recv
同理,由内核协议栈接收到tcb recvbuffer, 然后由recv api拷贝到用户空间。
close
关闭tcp连接,协议栈内部做四次挥手。
listen, accept和connect内部发生了什么
listen, accept和connect三个接口内部做的事情主要是tcp的三次握手。三次握手发生在服务器和客户端的内核协议栈中,对用户是隐藏的(用户态协议栈不在这范畴)。具体是如何运行:
三次握手是如何握手以及实现
三次握手是发生在内核协议栈之间的,对用户来说是无法感知到。具体流程如下:
- 客户端内核协议栈发送同步包,包含syn seqnum;
- 服务端内核协议栈收到syn包后,回应一个ack acknum和服务端的syn
seqnum; - 客户端收到了服务端的syn seqnum后再回复一个ack acknum, 至此完成三次握手;
三次握手是一个双向过程,客户端发送syn包后,服务器回复ack的同时附带syn同步请求包。acknum=1235表示1235包之前的包都收到了。三次握手要三次的原因是通信是双向的,客户端需要连接服务端需要请求和确认,服务端和客户端连接也需要请求和确认,中间一次确认和请求合并到一起。
具体发生在客户端connect,服务端发生在listen之后accept完成之前,服务端listen开启监听后。
- connect调用之后协议栈发送同步头,服务端被动等待到请求,创建一个5元组,构建一个节点叫tcb放入半连接队列(或syn队列)中, 因为需要响应多个客户端,因此采用队列存放异步处理。创建tcb后回复ack及syn包, connect如果是异步的,则当fd变为可写时表示连接成功。
- 服务器第三次握手请求后通过5元组去半连接队列中查找tcb然后放入全连接队列并且通过accept返回到用户空间, accept从全连接队列取出tcb创建一个fd返回,之后操作fd就是操作与之对应的tcb。
- 如果其中ack包没有接收到,则会重发,超时后会丢掉连接。
- 服务器就是通过端口复用,fd->tcb, tcb通过五元组区分,因此单台服务器能连接超过65535个socket。
close断开连接发生的什么
TCP/IP的四次挥手
断开只有一个函数close, 但是状态迁移上有6个状态。四次挥手只有个主动方和被动方.
- 调用close后,协议栈会置fin位为1,然后发送fin包。
- 被动方接收到fin包,准备一个空包返回给被动方应用层api recv接口返回0, 然后回复ack包。
- 被动方recv返回0后调用close,再发送fin包到主动方,然后主动方再回复ack, 至此四次挥手完成,连接断开。
上面这是主动方调用close关闭连接,但存在另一种情况,就是双方同时调用close的情况.
主动方和被动方同时发送fin, 主动方再fin_wait_1的时候收到了fin包,进入了closing的状态.
断开过程中可能出现的问题
- 当主动方出现大量的fin_wait_2的状态,被动方出现大量的close_wait的状态就是服务器没有调用close接口。主要在于recv到close之间执行时间太长。
- 当出现last_ack, fin_wait_1状态,不用关心,会重传,只需等待即可。
- 客户端解决fin_wait_2不好解决,设置keeplive时间,等待超时终止掉。
- 当出现大量的time_wait,设置reused解决. time_wait是避免最后一个ack的丢失,time_wait默认120s,可以修改的。
被动方在调用close后fd被回收,tcb在last_ack后回收,而主动方在time_wait时间到了后回收。
send和recv中间发生了什么
调用send函数将数据拷贝到内核协议栈,由内核协议栈之间进行传输,当客户端内核协议栈的send返回-1表示sendbuffer已经满了,发送失败。服务端内核协议栈接收数据时会告诉客户端内核协议栈服务端的recvbuffer还有多少空余空间,保证在recvbuffer满的时候不再发送,致使客户端send失败。recvbuffer在接收数据时,有个push标识位,置1时会立即通知客户端。
粘包和分包问题处理
当多次send都拷贝到内核协议栈,内核协议栈会自行组包进行发送,从而产生了粘包和分包问题。
解决粘包和分包问题:
- 协议头中增加包长。
- 添加分隔符。
但是这个前提是tcp包是顺序的,即先发先到,后发后到。那么如何保证顺序的呢? 采用延时ack来解决这问题。
延时ACK
延迟ack:
- 数据包发送有个mtu是确定的。
- 发送1号包,服务端启动定时器,延时回复ack.
- 当2号,3号,5号包到达重置定时器。
- 当超时触发,根据mtu发现4号包没到,则回复3号包的ack,从4号包开始重传。
MTU: Maximum Transmit Unit,最大传输单元,即物理接口(数据链路层)提供给其上层(通常是IP层)最大一次传输数据的大小;以普遍使用的以太网接口为例,缺省MTU=1500Byte,这是以太网接口对IP层的约束,如果IP层有<=1500 byte 需要发送,只需要一个IP包就可以完成发送任务;如果IP层有>1500 byte 数据需要发送,需要分片才能完成发送,这些分片有一个共同点,即IP Header ID相同。
通过延时ACK的方式保证了包的顺序,但当发生文件时又是如何操作的呢?
滑动窗口
客户端发送文件到服务器,发送条件:
- 发送1M的文件。
- Sendbuffer = 2k
- mss = 512
- mtu = 1500
MSS:Maximum Segment Size ,TCP提交给IP层最大分段大小,不包含TCP Header和 TCP Option,只包含TCP Payload ,MSS是TCP用来限制application层最大的发送字节数。如果底层物理接口MTU= 1500 byte,则 MSS = 1500- 20(IP Header) -20 (TCP Header) = 1460 byte,如果application 有2000 byte发送,需要两个segment才可以完成发送,第一个TCP segment = 1460,第二个TCP segment = 540。发送的方式应该是一个:
while(1) { poll(fd, ); // 判断是否可写 send(fd, buffer, 1k, 0); }
客户端在进入发送状态后,发送到服务器后,回复了window空余空间,当等于0时,客户端不再发送。
那么服务器处理了数据后,客户端如何知道服务器已经可以接收数据了呢? 客户端接收到服务器window=0时,启动探测定时器,间隔发送探测包来访问服务器的window。
滑动窗口ack回复第7包的seqnum,表示前面的包都收到了,然后从8开始重发,依次移动两个指针。
发送过程这里还存在一个问题,就是发送频率问题,在tcp中叫做拥塞控制,tcp协议栈有个慢启动的机制。
慢启动
慢启动的过程会先发送1个mss, 然后2mss, 4mss,成对数形式增长。发送次数到达16次(默认值)后线性增长,后面部分称为拥塞控制。
那么,如何判断数据包超出网络负载? 使用超时时间控制,一次发送到ACK返回的时间为RTT,RTT突然变大称为抖动。
rtt = 0.1 * rtt(最近的一次) + 0.9 * rtt(之前的)
上述公式就是一个消抖的过程. 一旦rtt超时后mss数量降半。
如上图,当发送一个mss时,回复ack没有超过rtt, 则下一次发送2个mss, 回复ack没有超过rtt, 则再下一次发送8个mss, 依次增加发送mss, 这个过程就是慢启动过程。
seqnum的含义?
seqnum记录的字节数量,初始值为随机值,当到了最大后从新从0开始。比如第一包seqnum = 1000, 第二包长度512,则第二包seqnum=10512;
为什么udp协议头有包长,而tcp协议头没有包长?
tcp有个mss(最大传输片),不管sendbuf多大,都会切割成mss长度发送。
每次发送都会拆成mss大小的一包数据发送到服务器,所以tcp包协议头不需要长度. Tcp的前后seqnum的差就是包的长度。