TCP协议是一个安全的、面向连接的、流式传输协议,所谓的面向连接就是三次握手,对于程序猿来说只需要在客户端调用connect()函数,三次握手就自动进行了。先通过下图看一下TCP协议的格式,然后再介绍三次握手的具体流程。
TCP的三次握手和四次挥手
在Tcp协议中,比较重要的字段有:
源端口:表示发送端端口号,字段长 16 位,2个字节
目的端口:表示接收端端口号,字段长 16 位,2个字节
序号(sequence number):字段长 32 位,占4个字节,序号的范围为 [0,4284967296]。
由于TCP是面向字节流的,在一个TCP连接中传送的字节流中的每一个字节都按顺序编号。
首部中的序号字段则是指本报文段所发送的数据的第一个字节的序号,这是随机生成的。
序号是循环使用的,当序号增加到最大值时,下一个序号就又回到了0
确认序号(acknowledgement number):占32位(4字节),表示收到的下一个报文段的第一个数据字节的序号,如果确认序号为N,序号为S,则表明到序号N-S为止的所有数据字节都已经被正确地接收到了。
8个标志位(Flag):
CWR:CWR 标志与后面的 ECE 标志都用于 IP 首部的 ECN 字段,ECE 标志为 1 时,则通知对方已将拥塞窗口缩小;
ECE:若其值为 1 则会通知对方,从对方到这边的网络有阻塞。在收到数据包的 IP 首部中 ECN 为 1 时将 TCP 首部中的 ECE 设为 1.;
URG:该位设为 1,表示包中有需要紧急处理的数据,对于需要紧急处理的数据,与后面的紧急指针有关;
ACK:该位设为 1,确认应答的字段有效,TCP规定除了最初建立连接时的 SYN 包之外该位必须设为 1;
PSH:该位设为 1,表示需要将收到的数据立刻传给上层应用协议,若设为 0,则先将数据进行缓存;
RST:该位设为 1,表示 TCP 连接出现异常必须强制断开连接;
SYN:用于建立连接,该位设为 1,表示希望建立连接,并在其序列号的字段进行序列号初值设定;
FIN:该位设为 1,表示今后不再有数据发送,希望断开连接。
窗口大小:该字段长 16 位,表示从确认序号所指位置开始能够接收的数据大小,TCP 不允许发送超过该窗口大小的数据。
三次握手
Tcp连接是双向连接,客户端和服务器需要分别向对方发送连接请求,并且建立连接,三次握手成功之后,二者之间的双向连接也就成功建立了。
三次握手具体过程如下:
第一次握手:
客户端:客户端向服务器端发起连接请求将报文中的SYN字段置为1,生成随机序号x,seq=x
服务器端:接收客户端发送的请求数据,解析tcp协议,校验SYN标志位是否为1,并得到序号 x
第二次握手:
服务器端:给客户端回复数据
回复ACK, 将tcp协议ACK对应的标志位设置为1,表示同意了客户端建立连接的请求
回复了 ack=x+1, 这是确认序号
x: 客户端生成的随机序号
1: 客户端给服务器发送的数据的量, SYN标志位存储到某一个字节中, 因此按照一个字节计算,表示客户端给服务器发送的1个字节服务器收到了。
将tcp协议中的SYN对应的标志位设置为 1, 服务器向客户端发起了连接请求
服务器端生成了一个随机序号 y, 发送给了客户端
客户端:接收回复的数据,并解析tcp协议
校验ACK标志位,为1表示服务器接收了客户端的连接请求
数据校验,确认发送给服务器的数据服务器收到了没有,计算公式如下:
发送的数据的量 = 使用服务器回复的确认序号 - 客户端生成的随机序号 ===> 1=x+1-x
校验SYN标志位,为1表示服务器请求和客户端建立连接
得到服务器生成的随机序号: y
第三次握手:
客户端:发送数据给服务器
将tcp协议中ACK标志位设置为1,表示同意了服务器的连接请求
给服务器回复了一个确认序号 ack = y+1
y:服务器端生成的随机序号
1:服务器给客户端发送的数据量,服务器给客户端发送了ACK和SYN, 都存储在这一个字节中
发送给服务器的序号就是上一次从服务器端收的确认序号因此 seq = x+1
服务器端:接收数据, 并解析tcp协议
查看ACK对应的标志位是否为1, 如果是1代表, 客户端同意了服务器的连接请求
数据校验,确认发送给客户端的数据客户端收到了没有,计算公式如下:
给客户端发送的数据量 = 确认序号 - 服务器生成的随机序号 ===> 1=y+1-y
得到客户端发送的序号:x+1
四次挥手
四次挥手是断开连接的过程,需要双向断开,关于由哪一端先断开连接是没有要求的。通信的两端如果想要断开连接就需要调用close()函数,当两端都调用了该函数,四次挥手也就完成了。
基于上图的例子对四次挥手的具体过程进行阐述(实际上那端先断开连接都是允许的):
第一次挥手:
主动断开连接的一方:发送断开连接的请求
将tcp协议中FIN标志位设置为1,表示请求断开连接
发送序号x给对端,seq=x,基于这个序号用于客户端数据校验的计算
被动断开连接的一方:接收请求数据, 并解析TCP协议
校验FIN标志位是否为1
收到了序号 x,基于这个数据计算回复的确认序号 ack 的值
第二次挥手:
被动断开连接的一方:回复数据
同意了对方断开连接的请求,将ACK标志位设置为1
回复 ack=x+1,表示成功接受了客户端发送的一个字节数据
向客户端发送序号 seq=y,基于这个序号用于服务器端数据校验的计算
主动断开连接的一方:接收回复数据, 并解析TCP协议
校验ACK标志位,如果为1表示断开连接的请求对方已经同意了
校验 ack确认发送的数据服务器是否收到了,发送的数据 = ack - x = x + 1 -x = 1
第三次挥手:
被动断开连接的一方:将tcp协议中FIN标志位设置为1,表示请求断开连接
主动断开连接的一方:接收请求数据, 并解析TCP协议,校验FIN标志位是否为1
第四次挥手:
主动断开连接的一方:回复数据
将tcp协议中ACK对应的标志位设置为1,表示同意了断开连接的请求
ack=y+1,表示服务器发送给客户端的一个字节客户端接收到了
序号 seq=h,此时的h应该等于 x+1,也就是第三次挥手时服务器回复的确认序号ack的值
被动断开连接的一方:收到回复的ACK, 此时双向连接双向断开, 通信的两端没有任何关系了
TCP滑动窗口
滑动窗口是一种流量控制的技术,早期的网络通信中,通信双方不会考虑网络的拥挤情况直接发送数据,由于大家不知道网络拥塞状况,同时发送数据,导致中间节点阻塞掉包,谁也发不了数据,所以就有了滑动窗口机制来解决此问题,滑动窗口协议是用来改善吞吐的一种技术,即容许发送方再接收任何应答之前传送附加的包,接收方告诉发送方在某一时刻能送多少包(称窗口尺寸)。
TCP中采用滑动窗口机制来进行传输控制,滑动窗口的大小意味着接收方还有多大的缓冲区可以用于接收数据,发送方可以通过滑动窗口的大小来确定该发送多少字节的数据,当滑动窗口为0时,发送方一般不能再发送数据报。
滑动窗口是TCP中实现诸如ACK确认,流量控制,拥塞控制的承载结构。
窗口理解为缓冲区的大小
滑动窗口的大小会随着发送数据和接收数据而变化。
通信的双方都有发送缓冲区和接收数据的缓冲区。
TCP状态转换
在TCP进行三次握手,或者四次挥手的过程中,通信的服务器和客户端内部会发送状态上的变化,发生的状态变化在程序中是看不到的,这个状态的变化也不需要程序猿去维护,但是在某些情况下进行程序的调试会去查看相关的状态信息,先来看三次握手过程中的状态转换。
TCP半关闭
TCP连接只有一方发送了FIN,另一方没有发出FIN包,仍然可以在一个方向上正常发送数据,这中状态可以称之为半关闭或者半连接。当四次挥手完成两次的时候,就相当于实现了半关闭,在程序中只需要在某一端直接调用 close() 函数即可。套接字通信默认是双工的,也就是双向通信,如果进行了半关闭就变成了单工,数据只能单向流动了。比如下面的这个例子:
服务器端:
调用了close() 函数,因此不能发数据,只能接收数据
关闭了服务器端的写操作,现在只能进行读操作 --> 变成了读端
客户端:
没有调用close(),客户端和服务器的连接还保持着
客户端可以给服务器发送数据,也可以接收服务器发送的数据 (但是,服务器已经丧失了发送数据的能力),因此客户端也只能发送数据,接收不到数据 --> 变成了写端
按照上述流程做了半关闭之后,从双工变成了单工,数据单向流动的方向: 客户端 -----> 服务器端。
cpp
// 专门处理半关闭的函数
#include <sys/socket.h>
// 可以选择关闭读或者写,close()只能关闭写操作
int shutdown(int sockfd, int how);
参数:
sockfd: 要操作的文件描述符
how:
SHUT_RD: 关闭文件描述符对应的读操作
SHUT_WR: 关闭文件描述符对应的写操作
SHUT_RDWR: 关闭文件描述符对应的读写操作
返回值:函数调用成功返回0,失败返回-1
端口复用
在网络通信中,一个端口只能被一个进程使用,不能多个进程共用同一个端口。我们在进行套接字通信的时候,如果按顺序执行如下操作:先启动服务器程序,再启动客户端程序,然后关闭服务器进程,再退出客户端进程,最后再启动服务器进程,就会出如下的错误提示信息:
cpp
bind error: Address already in use
cpp
# 第二次启动服务器进程
$ ./server
bind error: Address already in use
$ netstat -apn|grep 9999
(Not all processes could be identified, non-owned process info
will not be shown, you would have to be root to see it all.)
tcp 0 0 127.0.0.1:9999 127.0.0.1:50178 TIME_WAIT -
通过netstat查看TCP状态,发现上一个服务器进程其实还没有真正退出。因为服务器进程是主动断开连接的进程, 最后状态变成了 TIME_WAIT状态,这个进程会等待2msl(大约1分钟)才会退出,如果该进程不退出,其绑定的端口就不会释放,再次启动新的进程还是使用这个未释放的端口,端口被重复使用,就是提示bind error: Address already in use这个错误信息。
如果想要解决上述问题,就必须要设置端口复用,使用的函数原型如下:
cpp
// 这个函数是一个多功能函数, 可以设置套接字选项
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
参数:
sockfd:用于监听的文件描述符
level:设置端口复用需要使用 SOL_SOCKET 宏
optname:要设置什么属性(下边的两个宏都可以设置端口复用)
SO_REUSEADDR
SO_REUSEPORT
optval:设置是去除端口复用属性还是设置端口复用属性,实际应该使用 int 型变量
0:不设置
1:设置
optlen:optval指针指向的内存大小 sizeof(int)
这个函数应该添加到服务器端代码中,具体应该放到什么位置呢?答:在绑定之前设置端口复用。
1.阻塞,非阻塞,同步,异步
典型的一次IO的两个阶段是什么?
1.数据准备;2。数据读写。
数据准备:根据系统IO操作的就绪状态。
阻塞:调用IO方法的线程进入阻塞状态。
非阻塞:不会改变线程的状态,通过返回值来判断。
数据读写:根据应用程序和内核的交互方式。
同步/异步
陈硕大神原话:在处理 IO 的时候,阻塞和非阻塞都是同步 IO。只有使用了特殊的 API 才是异步
IO。
同步:A操作等待B操作做完事情,得到返回值,继续处理。
异步:A操作告诉B操作它感兴趣的事件以及通知方式,A操作继续执行自己的业务逻辑,等B监听到相应事件发生后,B会通知A,A开始相应的数据处理逻辑。
Linux内核中提供了两个异步IO的接口:aio_write和aio_read.
一个典型的网络IO接口调用,分为两个阶段,分别是"数据就绪"和"数据读写",数据就绪阶
段分为阻塞和非阻塞,表现得结果就是,阻塞当前线程或是直接返回。
同步表示A向B请求调用一个网络IO接口时(或者调用某个业务逻辑API接口时),数据的读写都是由请求方A自己来完成的(不管是阻塞还是非阻塞);异步表示A向B请求调用一个网络IO接口时(或者调用某个业务逻辑API接口时),向B传入请求的事件以及事件发生时通知的方式,A就可以处理其它逻辑了,当B监听到事件处理完成后,会用事先约定好的通知方式,通知A处理结果。
同步阻塞 int size = recv(fd, buf, 1024, 0)
同步非阻塞 int size = recv(fd, buf, 1024, 0)
异步阻塞
异步非阻塞
例如,Node.js: 基于异步非阻塞模式下的高性能服务器。
业务层面的一个逻辑过程,是同步还是异步过程??
2.Linux上的五种IO模型
阻塞 blocking
调用者调用了某个函数,等待这个函数返回,期间什么也不做,不停的去检查这个函数有没有返回,必须等这个函数返回才能进行下一步操作。
非阻塞 non-blocking
非阻塞等待,每隔一段时间就去检测IO事件是否就绪,没有就绪就可以做其他事,非阻塞I/O执行系统调用总是立即返回,不管事件是否已经发生,若事件没有发生,就返回-1,此时可以根据errno区分这两种情况,对于accept,recv和send,事件未发生时,errno通常被设置为EAGAIN。
IO复用(IO multiplexing)
Linux用select/poll/epoll函数实现IO复用型,这些函数也会使进程阻塞,但是和阻塞IO所不同的是这些函数可以同时阻塞多个IO操作,而且可以同时对多个读操作,写操作的IO函数进行检测,直到有数据可读或可写时,才真正调用IO操作函数。
信号驱动 (signal-driven)
Linux用套接口进行信号驱动IO,安装一个信号处理函数,进程继续运行并不阻塞,当IO事件就绪,进程收到SIGIO信号,然后处理IO事件。
内核在第一个阶段是异步,在第二个阶段是同步;与非阻塞IO的区别在于它提供了消息通知机制,不需要用户进程不断的轮询检查,减少了系统API的调用次数,提高了效率。
异步 (asynchronous)
Linux中,可以调用aio_read函数告诉内核描述字换缓冲区指针和缓冲区大小,文件便宜以及通知的方式,然后立即返回,当内核将数据拷贝到缓冲区后,在通知应用程序。虽然它的效率最高,但是它在设计的时候较为困难,难以排查错误。
cpp
struct aiocb
{
int aio_fildes
off_t aio_offset
volatile void *aio_buf
size_t aio_nbytes
int aio_reqprio
struct sigevent aio_sigevent
int aio_lio_opcode
}
3.怎么设计出好的网络服务器
在这个多核时代,服务端网络编程如何选择线程模型呢? 赞同libevent作者的观点:one loop per thread is usually a good model,这样多线程服务端编程的问题就转换为如何设计一个高效且易于使用的event loop,然后每个线程run一个event loop就行了(当然线程间的同步、互斥少不了,还有其它的耗时事件需要起另外的线程来做)。
event loop 是 non-blocking 网络编程的核心,在现实生活中,non-blocking 几乎总是和 IO
multiplexing 一起使用,原因有两点:
没有人真的会用轮询 (busy-pooling) 来检查某个 non-blocking IO 操作是否完成,这样太浪费CPU资源了。
IO-multiplex 一般不能和 blocking IO 用在一起,因为 blocking IO 中,read()/write()/accept()/connect() 都有可能阻塞当前线程,这样线程就没办法处理其他 socket上的 IO 事件了。
所以,当我们提到 non-blocking 的时候,实际上指的是 non-blocking + IO-multiplexing,单用其中任何一个都没有办法很好的实现功能。
强大的nginx服务器采用了epoll+fork模型作为网络模块的架构设计,实现了简单好用的负载算法,使各个fork网络进程不会忙的越忙、闲的越闲,并且通过引入一把乐观锁解决了该模型导致的服务器惊群现象,功能十分强大。
3.IO复用接口编程select,poll,epoll编程
select、poll 以及 epoll 是 Linux 系统的三个系统调用,也是 IO 多路复用模型的具体实现。
我们可以知道,IO 多路复用就是通过一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作的一种机制。
IO 多路复用的优点
与多进程和多线程技术相比,IO 多路复用技术的最大优势是系统开销小,系统不必创建进程或线程,也不必维护这些进程,从而大大减小了系统的开销。
select和poll
select实现多路复用的方式是,将已经连接的socket都放到一个文件描述符集合中,然后调用select函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此socket标记为可读或可写,接着再把整个文件描述符集合拷贝回用户态,然后用户态还需要再通过遍历的方法找到可读或可写的socket,然后对其处理。 select使用固定长度的BitMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在Linux系统中,由内核中的FD_SETSIZE限制,默认最大值为1024,只能监听0到1023的文件描述符。
poll不再使用BitMap来存储所关注的文件描述符,取而代之的使用动态数组,以链表的形式来组织,突破了select的文件描述符个数限制,当人还会受到系统文件描述符的限制。
select和poll并没有太大的本质区别,都是使用线性结构存储进程关注的socket集合,因此都需要遍历文件描述符集合来找到可读或可写的socket,时间复杂度为O(n),而且也需要在用户态和内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。
以select模型为例,假设我们的服务器需要支持100万的并发连接,则在__FD_SETSIZE 为1024的情况下,则我们至少需要开辟1k个进程才能实现100万的并发连接。除了进程间上下文切换的时间消耗外,从内核/用户空间大量的句柄结构内存拷贝、数组轮询等,是系统难以承受的。因此,基于select模型的服务器程序,要达到100万级别的并发访问,是一个很难完成的任务。
epoll
epoll的实现机制与select/poll机制完全不同,它们的缺点在epoll上不复存在。
设想一下如下场景:有100万个客户端同时与一个服务器进程保持着TCP连接。而每一时刻,通常只有几百上千个TCP连接是活跃的(事实上大部分场景都是这种情况)。如何实现这样的高并发?
在select/poll时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到内核态),让操作系统内核去查询这些套接字上是否有事件发生,轮询完成后,再将句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。
epoll的设计和实现与select完全不同。epoll通过在Linux内核中申请一个简易的文件系统(文件系统一般用什么数据结构实现?B+树,磁盘IO消耗低,效率很高)。把原先的select/poll调用分成以下3个部分:
1)调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)
2)调用epoll_ctl向epoll对象中添加这100万个连接的套接字
3)调用epoll_wait收集发生的事件的fd资源
如此一来,要实现上面说是的场景,只需要在进程启动时建立一个epoll对象,然后在需要的时候向这个epoll对象中添加或者删除事件。同时,epoll_wait的效率也非常高,因为调用epoll_wait时,并没有向操作系统复制这100万个连接的句柄数据,内核也不需要去遍历全部的连接。
epoll_create在内核上创建的eventpoll结构如下:
cpp
struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双向链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};
LT模式
内核数据没被读完,就会一直上报数据。
不会丢失数据或者消息
应用没有读取完数据,内核是会不断上报的
低延迟处理
每次读数据只需要一次系统调用;照顾了多个连接的公平性,不会因为某个连接上的数据量过大而影响其他连接处理消息
跨平台处理
像select一样可以跨平台使用
ET模式
内核数据只上报一次。