于是搜索到了这篇博客,收获良多。本篇博文大多直接从原创拷贝,博主尝试去改进或增加自己的见解,但无奈知识储备不够丰富,语言表达也没有原作好。原创地址在博客园:https://www.cnblogs.com/f-ck-need-u/p/7623252.html
{protocol,src_addr,src_port,dest_addr,dest_port}。
这常被称为套接字的五元组。其中protocol指定了是TCP还是UDP连接,其余的分别指定了源地址、源端口、目标地址、目标端口。
socket()函数的作用就是生成一个用于通信的套接字文件描述符。
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); //用于网络链接的ipv4的socket TCP连接
服务器程序在分析配置文件后获得想要监听的ip地址和端口后,加上可以通过socket()函数生成的套接字sockfd,使用bind()函数将这个套接字绑定到要监听的地址和端口组合"addr:port"上。绑定了端口的套接字可以作为listen()函数的监听对象。
绑定了地址和端口的套接字就有了源地址和源端口(对服务器自身来说是源),再加上通过配置文件中指定的协议类型,五元组中就有了其中3个元组。即:
{protocal,src_addr,src_port}
TCP协议是面向字节流的传输协议,要通过TCP连接发送出去的数据都先拷贝到send buffer,可能是从用户空间进程的app buffer拷入的,也可能是从内核的kernel buffer拷入的,拷入的过程是通过send()函数完成的。
最终数据是通过网卡流出去的,所以send buffer中的数据需要拷贝到网卡中。由于一端是内存,一端是网卡设备,可以直接使用DMA(Direct Memory Access,直接存储器访问,外部设备不通过CPU而直接与系统内存交换数据的接口)的方式进行拷贝,无需CPU的参与。也就是说,send buffer中的数据通过DMA的方式拷贝到网卡中并通过网络传输给TCP连接的另一端:接收端。
当通过TCP连接接收数据时,数据肯定是先通过网卡流入的,然后同样通过DMA的方式拷贝到recv buffer中,再通过recv()函数将数据从recv buffer拷入到用户空间进程的app buffer中。
connect()函数则用于向某个已监听的套接字发起连接请求,也就是发起TCP的三次握手过程。从这里可以看出,连接请求方(如客户端)才会使用connect()函数,当然,在发起connect()之前,连接发起方也需要生成一个sockfd,且使用的很可能是绑定了随机端口的套接字。既然connect()函数是向某个套接字发起连接的,自然在使用connect()函数时需要带上连接的目的地,即目标地址和目标端口,这正是服务端的监听套接字上绑定的地址和端口。同时,它还要带上自己的地址和端口,对于服务端来说,这就是连接请求的源地址和源端口。于是,TCP连接的两端的套接字都已经成了五元组的完整格式。
_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); //用于网络链接的ipv4的socket TCP连接
sockaddr_in _sin = {};
_sin.sin_family = AF_INET;
_sin.sin_port = htons("4567"); //host to net short
_sin.sin_addr.S_un.S_addr = inet_addr("127.0.0.1"); //本地连接
connect(_sock, (sockaddr*)&_sin, sizeof(sockaddr_in));
listen()函数就是监听已经通过bind()绑定了addr+port的套接字的。监听之后,套接字就从CLOSE状态转变为LISTEN状态,于是这个套接字就可以对外提供TCP连接的窗口(socket buffer)了。
实际业务场景中,服务器与客户端一般是1对多或多对多的连接,如果服务器程序监听了多个地址+端口,即需要监听多个套接字,那么负责监听的进程/线程会采用select()、poll()的方式去轮询这些套接字,或者可以使用epoll()模式。
select()、poll()、epoll()都是I/O复用的函数,epoll()是linux内核提供的高效监听大量套接字的方法,它们各有优缺点,因此各有使用场景。详情可见:https://github.com/CyC2018/CS-Notes/blob/master/notes/Socket.md
1、SYN_RECV 连接未完成队列
根据本文的第一张图可知,客户端发起connect()到处于listen()状态的服务器后,进入TCP三次握手过程。无论是使用select()、poll(),在进程/线程(监听者)监听的过程中,它阻塞在select()或poll()上。直到有数据(连接请求,SYN信息)写入到它所监听的sockfd中(即recv buffer),内核被唤醒(注意不是app进程被唤醒,因为TCP三次握手和四次挥手是在内核空间由内核完成的,不涉及用户空间),并将SYN数据拷贝到kernel buffer中进行一番处理(比如判断SYN是否合理),并准备SYN+ACK数据(数据需要从kernel buffer中拷入send buffer中,再拷入网卡传送出去)。这时会在连接未完成队列(syn queue)中为这个连接创建一个新项目,并设置为SYN_RECV状态。
2、accept queue/established queue 连接完成队列
此时,循环使用select()/poll()方式监控着套接字listenfd,直到再次有数据写入这个listenfd中,内核再次被唤醒,如果这次写入的数据是ACK信息(第三次握手),表示是某个客户端对服务端内核发送的SYN的回应,于是将数据(ACK数据)拷入到kernel buffer中进行一番处理后,把连接未完成队列中对应的项目移入连接已完成队列(accept queue/established queue),并设置为ESTABLISHED状态。
如果这次接收的不是ACK,则肯定是SYN,也就是新的连接请求,于是和上面的处理过程一样,放入连接未完成队列。对于已经放入已完成队列中的连接,将等待内核通过accept()函数进行消费(由用户空间进程发起accept()系统调用,由内核完成消费操作),只要经过accept()过的连接,连接将从已完成队列中移除,也就表示TCP已经建立完成了,两端的用户空间进程可以通过这个连接进行真正的数据传输了,直到使用close()或shutdown()关闭连接时的4次挥手,中间再也不需要内核的参与。这就是监听者处理整个TCP连接的循环过程。
3、总结
listen()函数还维护了两个队列:**连接未完成队列(syn queue)和连接已完成队列(accept queue)。**当监听者接收到某个客户端发来的SYN并回复了SYN+ACK之后,就会在未完成连接队列的尾部创建一个关于这个客户端的条目,并设置它的状态为SYN_RECV。显然,这个条目中必须包含客户端的地址和端口相关信息(盲猜:队列应该是hash表)。当服务端再次收到这个客户端发送的ACK信息之后,监听者线程通过分析数据就知道这个消息是回复给未完成连接队列中的哪一项的,于是将这一项移入到已完成连接队列,并设置它的状态为ESTABLISHED,最后等待内核使用accept()函数来消费接收这个连接。从此开始,内核暂时退出舞台,直到4次挥手。
当未完成连接队列满了,监听者被阻塞不再接收新的连接请求,并通过select()/poll()等待两个队列触发可写事件。当已完成连接队列满了,则监听者也不会接收新的连接请求,同时,正准备移入到已完成连接队列的动作被阻塞。
当连接已完成队列中的某个连接被accept()后,表示TCP连接已经建立完成,这**个连接将采用自己的socket buffer和客户端进行数据传输。**这个socket buffer和监听套接字的socket buffer都是用来存储TCP收、发的数据,但它们的意义已经不再一样:监听套接字的socket buffer只接受TCP连接请求过程中的syn和ack数据;而已建立的TCP连接的socket buffer主要存储的内容是两端传输的"正式"数据(网络消息报文),例如服务端构建的响应数据,客户端发起的Http请求数据。
4、C++ Socket API中的select函数使用方法
fd_set fdRead; //select函数对一组socket集合进行检测,fd_set是socket数组
FD_ZERO(&fdRead); //清空fd集合的数据
FD_SET(_sock, &fdRead); //将服务端的socket放入列表
timeval t = { 0,10 }; //select查询超时的时间 windows下的计时器 目前没有计算微秒 0表示select函数如果查询没有需要处理,立即返回
int ret = select(_sock + 1, &fdRead, nullptr, nullptr, &t);
if (FD_ISSET(_sock, &fdRead)) //判断_sock是否在fdRead中, 如果在,表明需要去处理(一般accept()操作)
{
Accept(); //封装的accept()
}
此外,如果监听者发送SYN+ACK后,迟迟收不到客户端返回的ACK消息,监听者将被select()/poll()设置的超时时间唤醒,并对该客户端重新发送SYN+ACK消息,防止这个消息遗失在茫茫网络中。但是,这一重发就出问题了,如果客户端调用connect()时伪造源地址,那么监听者回复的SYN+ACK消息是一定到不了对方的主机的,也就是说,监听者会迟迟收不到ACK消息,于是重新发送SYN+ACK。但无论是监听者因为select()/poll()设置的超时时间一次次地被唤醒,还是一次次地将数据拷入send buffer,这期间都是需要CPU参与的,而且send buffer中的SYN+ACK还要再拷入网卡(这次是DMA拷贝,不需要CPU)。如果,这个客户端是个攻击者,源源不断地发送了数以千、万计的SYN,监听者几乎直接就崩溃了,网卡也会被阻塞的很严重。这就是所谓的syn flood攻击。
解决syn flood的方法有多种,例如,缩小listen()维护的两个队列的最大长度,减少重发syn+ack的次数,增大重发的时间间隔,减少收到ack的等待超时时间,使用syncookie等,但直接修改tcp选项的任何一种方法都不能很好兼顾性能和效率。所以在连接到达监听者线程之前对数据包进行过滤是极其重要的手段。
accpet()函数的作用是读取已完成连接队列中的第一项(读完就从队列中移除),并对此项生成一个用于后续连接的套接字描述符,假设使用connfd来表示。有了新的连接套接字,进程/线程就可以通过这个连接套接字和客户端进行数据传输。
accept()工作的两种模式:
1、prefork模式
每个子进程既是监听者,又是工作者,每个客户端发起连接请求时,子进程在监听时将它接收进来,并释放对监听套接字的监听,使得其他子进程可以去监听这个套接字。多个来回后,终于是通过accpet()函数生成了新的连接套接字,于是这个子进程就可以通过这个套接字专心地和客户端建立交互,当然,中途可能会因为各种io等待而多次被阻塞或睡眠。
2、worker/event模式
每个子进程中都使用了一个专门的监听线程和N个工作线程,也可以理解为生产者-消费者模型,生产者负责监听,将连接成功的socket放入到消费者线程的处理队列中;消费者线程则处理连接成功后的数据发送和接收工作。这样监听者和工作者就分开了,在监听的过程中,工作者可以仍然可以自由地工作。
当监听者发起accept()系统调用的时候,如果已完成连接队列中没有任何数据,那么监听者会被阻塞。当然,可将套接字设置为非阻塞模式,这时accept()在得不到数据时会返回EWOULDBLOCK或EAGAIN的错误。可以使用select()或poll()或epoll来等待已完成连接队列的可读事件。
3、总结
prefork模式效率是低的,仅仅考虑从子进程收到SYN消息开始到最后生成新的连接套接字这几个阶段,这个子进程一次又一次地被阻塞。当然,可以将监听套接字设置为非阻塞IO模式,只是即使是非阻塞模式,它也要不断地去检查状态。
worker/event模式比prefork模式性能高的,主要是因为prefork模式处理监听、连接和网络收发业务的耦合性太大,一旦某个环节出现了阻塞则直接影响其它业务的运行。worker/event模式将监听和工作分离,解耦合使得各个业务之间影响不大,当然多线程也有帮助提高效率,但不是关键。
每个tcp连接的两端都会关联一个套接字和该套接字指向的文件描述符。
当服务端收到了ack消息后,就表示三次握手完成了,表示和客户端的这个tcp连接已经建立好了。连接建立好的socket会放在listen()打开的established queue队列中等待accept()的消费。这个时候的tcp连接在服务端所关联的套接字是listen套接字和它指向的文件描述符。
当established queue中的tcp连接被accept()消费后,这个tcp连接就会关联accept()所指定的套接字,并分配一个新的文件描述符。也就是说,经过accept()之后,这个连接和listen套接字已经没有任何关系了。
连接还是那个连接,只不过服务端换掉了这个tcp连接所关联的套接字和文件描述符,而客户端并不知道这一切。但这并不影响双方的通信,因为数据传输是基于连接的而不是基于套接字的,只要能从文件描述符中将数据放入tcp连接这根"管道"里,数据就能到达另一端。
作者再次读到这里的时候,对上面这段话还是很费解。于是再次经过查阅资料后,做出如下的总结:
关于TCP服务器最大并发连接数有一种误解就是“因为端口号上限为65535,所以TCP服务器理论上的可承载的最大并发连接数也是65535”。
先说结论:对于TCP服务端进程来说,他可以同时连接的客户端数量并不受限于可用端口号。并发连接数受限于linux可打开文件数,这个数是可以配置的,可以非常大,所以实际上受限于系统性能。
TCP在建立连接之前,服务端首先会创建一个socket,并绑定一个端口port和主机IP。客户端在连接之前,也会创建一个socket,并由系统随机分配一个端口号port,并绑定了主机IP。于是双方三次握手结束后,服务端的已完成队列(established queue)存储了三次握手成功的客户端socket,等待服务端调用accept()消费。
于是,一个困惑了我很久的问题就产生了。当服务端accept一个请求后,生成的新的socket到底使用的是什么端口呢(系统会默认给其分配一个空闲的端口号?)?如果是一个空闲的端口,那一定不是服务端socket绑定的端口了。
再回想计算机网络的协议架构,TCP和UDP同属于传输层,共同架设在IP层(网络层)之上。而IP层主要负责的是在节点之间(End to End)的数据包传送,这里的节点是一台网络设备,比如计算机。因为IP层只负责把数据送到节点,而不能区分上面的不同应用,所以TCP和UDP协议在其基础上加入了端口的信息,端口于是标识的是一个节点上的一个应用。TCP协议还加入了更加复杂的传输控制,比如滑动的数据发送窗口(Slice Window),以及接收确认和重发机制,以达到数据的可靠传送。
因此,如果一个程序创建了一个socket,并让其监听80端口,其实是向TCP/IP协议栈声明了其对80端口的占有。以后,所有目标是80端口的TCP数据包都会转发给该程序(这里的应用程序,因为使用的是Socket编程接口,所以首先由Socket层来处理)。所谓accept函数,其实抽象的是TCP的连接建立过程。accept函数返回的新socket其实指代的是本次创建的连接,而一个连接是包括两部分信息的,一个是源IP和源端口,另一个是目标IP和目标端口。**所以,accept可以产生多个不同的socket,而这些socket里包含的目标IP和目标端口是不变的,变化的只是源IP和源端口。**这些socket目标端口就可以都是80,而Socket层还是能根据源/目标对来准确地分辨出IP数据包和socket的归属关系,从而完成对TCP/IP协议的操作封装!
send()函数是将数据从app buffer复制到send buffer中(当然,也可能直接从内核的kernel buffer中复制),recv()函数则是将recv buffer中的数据复制到app buffer中。
这两个函数都涉及到了socket buffer,但是在调用send()或recv()时,发送端的buffer中是否有数据、接收端的buffer中是否已满而导致不可写是需要考虑的问题。不管哪一方,只要不满足条件,调用send()/recv()时进程/线程会被阻塞(假设套接字设置为阻塞式IO模型)。当然,可以将套接字设置为非阻塞IO模型,这时在buffer不满足条件时调用send()/recv()函数,调用函数的进程/线程将返回错误状态信息EWOULDBLOCK或EAGAIN。
buffer中是否有数据、是否已满而导致不可写,其实可以使用select()/poll()/epoll去监控对应的文件描述符(对应socket buffer则监控该socket描述符),当满足条件时,再去调用send()/recv()就可以正常操作了。
但需要注意的是,TCP连接双方维护的数据缓冲区(窗口)一般为64字节,如果收发双方对于缓冲区数据的处理速度不一致,也就是发送速度远大于接收的速度时,会导致阻塞。因此处理好收发数据的代码逻辑,网络粘包/少包的处理是必要的。使用select()/poll()/epoll监控满足条件返回时,一般在服务器端程序设置一个接收数据的二级缓冲区(区别于socket缓冲区),一次性的取出socket缓冲区所有/一定数量的数据,从而降低socket缓冲区溢出的风险。对于二技缓冲区中取出的数据如何去处理,那么需要在程序开发中设计好网络传输报文的数据结构。