不可不知的socket和TCP链接过程

本文主要说明的是TCP链接过程当中,各个阶段对套接字的操做,但愿能对没有网络编程基础的人理解套接字是什么、扮演的角色有所帮助。如发现错误,敬请指出web

背景

1.TCP协议栈维护着两个socket缓冲区:send buffer和recv buffer算法

要经过TCP链接发送出去的数据都先拷贝到send buffer,多是从用户空间进程的app buffer拷入的,也多是从内核的kernel buffer拷入的,拷入的过程是经过send()函数完成的,因为也可使用write()函数写入数据,因此也把这个过程称为写数据,相应的send buffer也就有了别称write buffer。不过send()函数比write()函数更有效率。apache

最终数据是经过网卡流出去的,因此send buffer中的数据须要拷贝到网卡中。因为一端是内存,一端是网卡设备,能够直接使用DMA的方式进行拷贝,无需CPU的参与。也就是说,send buffer中的数据经过DMA的方式拷贝到网卡中并经过网络传输给TCP链接的另外一端:接收端。编程

当经过TCP链接接收数据时,数据确定是先经过网卡流入的,而后一样经过DMA的方式拷贝到recv buffer中,再经过recv()函数将数据从recv buffer拷入到用户空间进程的app buffer中。bash

大体过程以下图:服务器

2.两种套接字:监听套接字和已链接套接字cookie

监听套接字是在服务进程读取配置文件时,从配置文件中解析出要监听的地址、端口,而后经过socket()函数建立的,而后再经过bind()函数将这个监听套接字绑定到对应的地址和端口上。随后,进程/线程就能够经过listen()函数来监听这个端口(严格地说是监控这个监听套接字)。网络

已链接套接字是在监听到TCP链接请求并三次握手后,经过accept()函数返回的套接字,后续进程/线程就能够经过这个已链接套接字和客户端进行TCP通讯。并发

为了区分socket()函数和accept()函数返回的两个套接字描述符,有些人使用listenfd和connfd分别表示监听套接字和已链接套接字,挺形象的,下文偶尔也这么使用。app

下面就来讲明各类函数的做用,分析这些函数,也是在链接、断开链接的过程。

链接的具体过程分析

以下图:

socket()函数

socket()函数的做用就是生成一个用于通讯的套接字文件描述符sockfd(socket() creates an endpoint for communication and returns a descriptor)。这个套接字描述符能够做为稍后bind()函数的绑定对象。

bind()函数

服务程序经过分析配置文件,从中解析出想要监听的地址和端口,再加上能够经过socket()函数生成的套接字sockfd,就可使用bind()函数将这个套接字绑定到要监听的地址和端口组合"addr:port"上。绑定了端口的套接字能够做为listen()函数的监听对象。

绑定了地址和端口的套接字就有了源地址和源端口(对服务器自身来讲是源),再加上经过配置文件中指定的协议类型,五元组中就有了其中3个元组。即:

{protocal,src_addr,src_port}

可是,常见到有些服务程序能够配置监听多个地址、端口实现多实例。这实际上就是经过屡次socket()+bind()系统调用生成并绑定多个套接字实现的。

listen()函数和connect()函数

顾名思义,listen()函数就是监听已经经过bind()绑定了addr+port的套接字的。监听以后,套接字就从CLOSE状态转变为LISTEN状态,因而这个套接字就能够对外提供TCP链接的窗口了。

而connect()函数则用于向某个已监听的套接字发起链接请求,也就是发起TCP的三次握手过程。从这里能够看出,链接请求方(如客户端)才会使用connect()函数,固然,在发起connect()以前,链接发起方也须要生成一个sockfd,且使用的极可能是绑定了随机端口的套接字。既然connect()函数是向某个套接字发起链接的,天然在使用connect()函数时须要带上链接的目的地,即目标地址和目标端口,这正是服务端的监听套接字上绑定的地址和端口。同时,它还要带上本身的地址和端口,对于服务端来讲,这就是链接请求的源地址和源端口。因而,TCP链接的两端的套接字都已经成了五元组的完整格式。

深刻分析listen()

再来细说listen()函数。若是监听了多个地址+端口,即须要监听多个套接字,那么此刻负责监听的进程/线程会采用select()、poll()的方式去轮询这些套接字(固然,也可使用epoll()模式),其实只监控一个套接字时,也是使用这些模式去轮询的,只不过select()或poll()所感兴趣的套接字描述符只有一个而已。

无论使用select()仍是poll()模式(至于epoll的不一样监控方式就无需多言了), 在进程/线程(监听者)监听的过程当中,它阻塞在select()或poll()上。直到有数据(SYN信息)写入到它所监听的sockfd中(即recv buffer),内核被唤醒(注意不是app进程被唤醒,由于TCP三次握手和四次挥手是在内核空间由内核完成的,不涉及用户空间)并将SYN数据拷贝到kernel buffer中进行一番处理(好比判断SYN是否合理),并准备SYN+ACK数据,这个数据须要从kernel buffer中拷入send buffer中,再拷入网卡传送出去。这时会在链接未完成队列(syn queue)中为这个链接建立一个新项目,并设置为SYN_RECV状态。而后再次使用select()/poll()方式监控着套接字listenfd,直到再次有数据写入这个listenfd中,内核再次被唤醒,若是此次写入的数据是ACK信息,表示是某个客户端对服务端内核发送的SYN的回应,因而将数据拷入到kernel buffer中进行一番处理后,把链接未完成队列中对应的项目移入链接已完成队列(accept queue/established queue),并设置为ESTABLISHED状态,若是此次接收的不是ACK,则确定是SYN,也就是新的链接请求,因而和上面的处理过程同样,放入链接未完成队列。对于已经放入已完成队列中的链接,将等待内核经过accept()函数进行消费(由用户空间进程发起accept()系统调用,由内核完成消费操做),只要通过accept()过的链接,链接将从已完成队列中移除,也就表示TCP已经创建完成了,两端的用户空间进程能够经过这个链接进行真正的数据传输了,直到使用close()或shutdown()关闭链接时的4次挥手,中间不再须要内核的参与。这就是监听者处理整个TCP链接的循环过程

也就是说, listen()函数还维护了两个队列:链接未完成队列(syn queue)和链接已完成队列(accept queue)。当监听者接收到某个客户端发来的SYN并回复了SYN+ACK以后,就会在未完成链接队列的尾部建立一个关于这个客户端的条目,并设置它的状态为SYN_RECV。显然,这个条目中必须包含客户端的地址和端口相关信息(多是hash过的,我不太肯定)。当服务端再次收到这个客户端发送的ACK信息以后,监听者线程经过分析数据就知道这个消息是回复给未完成链接队列中的哪一项的,因而将这一项移入到已完成链接队列,并设置它的状态为ESTABLISHED,最后等待内核使用accept()函数来消费接收这个链接。今后开始,内核暂时退出舞台,直到4次挥手。

当未完成链接队列满了,监听者被阻塞再也不接收新的链接请求,并经过select()/poll()等待两个队列触发可写事件。当已完成链接队列满了,则监听者也不会接收新的链接请求,同时,正准备移入到已完成链接队列的动做被阻塞。在Linux 2.2之前,listen()函数有一个backlog的参数,用于设置这两个队列的最大总长度(其实是只有一个队列,但分为两种状态,见下面的"小知识"),从Linux 2.2开始,这个参数只表示已完成队列(accept queue)的最大长度,而/proc/sys/net/ipv4/tcp_max_syn_backlog则用于设置未完成队列(syn queue/syn backlog)的最大长度。/proc/sys/net/core/somaxconn则是硬限制已完成队列的最大长度,默认为128,若是backlog参数大于somaxconn,则backlog会被截短为该硬限制值。

当链接已完成队列中的某个链接被accept()后,表示TCP链接已经创建完成, 这个链接将采用本身的socket buffer和客户端进行数据传输。这个socket buffer和监听套接字的socket buffer都是用来存储TCP收、发的数据,但它们的意义已经再也不同样:监听套接字的socket buffer只接受TCP链接请求过程当中的syn和ack数据;而已创建的TCP链接的socket buffer主要存储的内容是两端传输的"正式"数据,例如服务端构建的响应数据,客户端发起的Http请求数据。

小知识:两种TCP套接字
实际上,有两种不一样类型的TCP套接字实现方式。上面介绍的使用两种队列的类型是Linux 2.2以后采用的一种。还有一种(BSD衍生)的套接字类型只采用了一个队列,在这单个队列中存放3次握手过程当中的全部链接,可是队列中的每一个链接分为两种状态:syn-recv和established。

Recv-Q和Send-Q的解释

netstat命令的Send-Q和Recv-Q列表示的就是socket buffer相关的内容,如下是man netstat的解释。

Recv-Q
    Established: The count of bytes not copied by the user program connected to this socket.  Listening: Since Kernel 2.6.18 this  column  contains the current syn backlog.

Send-Q
    Established:  The count of bytes not acknowledged by the remote host.  Listening: Since Kernel 2.6.18 this column contains the maximum size of the syn backlog.

对于监听状态的套接字,Recv-Q表示的是当前syn backlog,即堆积的syn消息的个数,也即未完成队列中当前的链接个数,Send-Q表示的是syn backlog的最大值,即未完成链接队列的最大链接限制个数;
对于已经创建的tcp链接,Recv-Q列表示的是recv buffer中还未被用户进程拷贝走的数据大小,Send-Q列表示的是远程主机还未返回ACK消息的数据大小。

之因此区分已创建TCP链接的套接字和监听状态的套接字,就是由于这两种状态的套接字采用不一样的socket buffer,其中监听套接字更注重队列的长度,而已创建TCP链接的套接字更注重收、发的数据大小。

[root@xuexi ~]# netstat -tnl
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State      
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN     
tcp        0      0 127.0.0.1:25            0.0.0.0:*               LISTEN     
tcp6       0      0 :::80                   :::*                    LISTEN     
tcp6       0      0 :::22                   :::*                    LISTEN     
tcp6       0      0 ::1:25                  :::*                    LISTEN
[root@xuexi ~]# ss -tnl
State      Recv-Q Send-Q                    Local Address:Port      Peer Address:Port
LISTEN     0      128                                   *:22                   *:*   
LISTEN     0      100                           127.0.0.1:25                   *:*   
LISTEN     0      128                                  :::80                  :::*   
LISTEN     0      128                                  :::22                  :::*   
LISTEN     0      100                                 ::1:25                  :::*

注意,Listen状态下的套接字,netstat的Send-Q和ss命令的Send-Q列的值不同,由于netstat根本就没写上未完成队列的最大长度。所以,判断队列中是否还有空闲位置接收新的tcp链接请求时,应该尽量地使用ss命令而不是netstat。

syn flood的影响

此外,若是监听者发送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选项的任何一种方法都不能很好兼顾性能和效率。因此在链接到达监听者线程以前对数据包进行过滤是极其重要的手段。

accept()函数

accpet()函数的做用是读取已完成链接队列中的第一项(读完就从队列中移除),并对此项生成一个用于后续链接的套接字描述符,假设使用connfd来表示。有了新的链接套接字,工做进程/线程(称其为工做者)就能够经过这个链接套接字和客户端进行数据传输,而前文所说的监听套接字(sockfd)则仍然被监听者监听。

例如,prefork模式的httpd,每一个子进程既是监听者,又是工做者,每一个客户端发起链接请求时,子进程在监听时将它接收进来,并释放对监听套接字的监听,使得其余子进程能够去监听这个套接字。多个来回后,终因而经过accpet()函数生成了新的链接套接字,因而这个子进程就能够经过这个套接字专心地和客户端创建交互,固然,中途可能会由于各类io等待而屡次被阻塞或睡眠。这种效率真的很低,仅仅考虑从子进程收到SYN消息开始到最后生成新的链接套接字这几个阶段,这个子进程一次又一次地被阻塞。固然,能够将监听套接字设置为非阻塞IO模式,只是即便是非阻塞模式,它也要不断地去检查状态。

再考虑worker/event处理模式,每一个子进程中都使用了一个专门的监听线程和N个工做线程。监听线程专门负责监听并创建新的链接套接字描述符,放入apache的套接字队列中。这样监听者和工做者就分开了,在监听的过程当中,工做者能够仍然能够自由地工做。若是只从监听这一个角度来讲,worker/event模式比prefork模式性能高的不是一点半点。

当监听者发起accept()系统调用的时候,若是已完成链接队列中没有任何数据,那么监听者会被阻塞。固然,可将套接字设置为非阻塞模式,这时accept()在得不到数据时会返回EWOULDBLOCK或EAGAIN的错误。可使用select()或poll()或epoll来等待已完成链接队列的可读事件。还能够将套接字设置为信号驱动IO模式,让已完成链接队列中新加入的数据通知监听者将数据复制到app buffer中并使用accept()进行处理。

常听到同步链接和异步链接的概念,它们究竟是怎么区分的?同步链接的意思是,从监听者监听到某个客户端发送的SYN数据开始,它必须一直等待直到创建链接套接字、并和客户端数据交互结束,在和这个客户端的链接关闭以前,中间不会接收任何其余客户端的链接请求。细致一点解释,那就是同步链接时须要保证socket buffer和app buffer数据保持一致。一般以同步链接的方式处理时,监听者和工做者是同一个进程,例如httpd的prefork模型。而异步链接则能够在创建链接和数据交互的任何一个阶段接收、处理其余链接请求。一般,监听者和工做者不是同一个进程时使用异步链接的方式,例如httpd的event模型,尽管worker模型中监听者和工做者分开了,可是仍采用同步链接,监听者将链接请求接入并建立了链接套接字后,当即交给工做线程,工做线程处理的过程当中一直只服务于该客户端直到链接断开,而event模式的异步也仅仅是在工做线程处理特殊的链接(如处于长链接状态的链接)时,能够将它交给监听线程保管而已,对于正常的链接,它仍等价于同步链接的方式,所以httpd的event所谓异步,实际上是伪异步。通俗而不严谨地说,同步链接是一个进程/线程处理一个链接,异步链接是一个进程/线程处理多个链接

tcp链接和套接字的关系

先明确一点,每一个tcp链接的两端都会关联一个套接字和该套接字指向的文件描述符

前面说过,当服务端收到了ack消息后,就表示三次握手完成了,表示和客户端的这个tcp链接已经创建好了。链接创建好的一开始,这个tcp链接会放在listen()打开的established queue队列中等待accept()的消费。这个时候的tcp链接在服务端所关联的套接字是listen套接字和它指向的文件描述符

当established queue中的tcp链接被accept()消费后,这个tcp链接就会关联accept()所指定的套接字,并分配一个新的文件描述符。也就是说,通过accept()以后,这个链接和listen套接字已经没有任何关系了。

换句话说,链接仍是那个链接,只不过服务端偷偷地换掉了这个tcp链接所关联的套接字和文件描述符,而客户端并不知道这一切。但这并不影响双方的通讯,由于数据传输是基于链接而不是基于套接字的,只要能从文件描述符中将数据放入tcp链接这根"管道"里,数据就能到达另外一端。

实际上,并不必定须要accept()才能进行tcp通讯,由于在accept()以前链接就以创建好了,只不过它关联的是listen套接字对应的文件描述符,而这个套接字只识别三次握手和四次挥手涉及到的数据,并且这个套接字中的数据是由操做系统内核负责的。能够想像一下,只有listen()没有accept()时,客户端不断地发起connect(),服务端将一直将创建仅只链接而不作任何操做,直到listen的队列满了。

send()和recv()函数

send()函数是将数据从app buffer复制到send buffer中(固然,也可能直接从内核的kernel buffer中复制),recv()函数则是将recv buffer中的数据复制到app buffer中。固然,对于tcp套接字来讲,更多的是使用write()和read()函数来发送、读取socket buffer数据,这里使用send()/recv()来讲明仅仅只是它们的名称针对性更强而已。

这两个函数都涉及到了socket buffer,可是在调用send()或recv()时,复制的源buffer中是否有数据、复制的目标buffer中是否已满而致使不可写是须要考虑的问题。无论哪一方,只要不知足条件,调用send()/recv()时进程/线程会被阻塞(假设套接字设置为阻塞式IO模型)。固然,能够将套接字设置为非阻塞IO模型,这时在buffer不知足条件时调用send()/recv()函数,调用函数的进程/线程将返回错误状态信息EWOULDBLOCK或EAGAIN。buffer中是否有数据、是否已满而致使不可写,其实可使用select()/poll()/epoll去监控对应的文件描述符(对应socket buffer则监控该socket描述符),当知足条件时,再去调用send()/recv()就能够正常操做了。还能够将套接字设置为信号驱动IO或异步IO模型,这样数据准备好、复制好以前就不用再作无用功去调用send()/recv()了。

close()、shutdown()函数

通用的close()函数能够关闭一个文件描述符,固然也包括面向链接的网络套接字描述符。当调用close()时,将会尝试发送send buffer中的全部数据。可是close()函数只是将这个套接字引用计数减1,就像rm同样,删除一个文件时只是移除一个硬连接数,只有这个套接字的全部引用计数都被删除,套接字描述符才会真的被关闭,才会开始后续的四次挥手中。对于父子进程共享套接字的并发服务程序,调用close()关闭子进程的套接字并不会真的关闭套接字,由于父进程的套接字还处于打开状态,若是父进程一直不调用close()函数,那么这个套接字将一直处于打开状态,将一直进入不了四次挥手过程。

而shutdown()函数专门用于关闭网络套接字的链接,和close()对引用计数减一不一样的是,它直接掐断套接字的全部链接,从而引起四次挥手的过程。能够指定3种关闭方式:

1.关闭写。此时将没法向send buffer中再写数据,send buffer中已有的数据会一直发送直到完毕。
2.关闭读。此时将没法从recv buffer中再读数据,recv buffer中已有的数据只能被丢弃。
3.关闭读和写。此时没法读、没法写,send buffer中已有的数据会发送直到完毕,但recv buffer中已有的数据将被丢弃。

不管是shutdown()仍是close(),每次调用它们,在真正进入四次挥手的过程当中,它们都会发送一个FIN。

地址/端口重用技术

正常状况下,一个addr+port只能被一个套接字绑定,换句话说,addr+port不能被重用,不一样套接字只能绑定到不一样的addr+port上。举个例子,若是想要开启两个sshd实例,前后启动的sshd实例配置文件中,必须不能配置一样的addr+port。同理,配置web虚拟主机时,除非是基于域名,不然两个虚拟主机必须不能配置同一个addr+port,而基于域名的虚拟主机能绑定同一个addr+port的缘由是http的请求报文中包含主机名信息,实际上在这类链接请求到达的时候,还是经过同一个套接字进行监听的,只不过监听到以后,httpd的工做进程/线程能够将这个链接分配到对应的主机上。

既然上面说的是正常状况下,固然就有非正常状况,也就是地址重用和端口重用技术,组合起来就是套接字重用。在如今的Linux内核中,已经有支持地址重用的socket选项SO_REUSEADDR和支持端口重用的socket选项SO_REUSEPORT。设置了端口重用选项后,再去绑定套接字,就不会再有错误了。并且,一个实例绑定了两个addr+port以后(能够绑定多个,此处以两个为例),就能够同一时刻使用两个监听进程/线程分别去监听它们,客户端发来的链接也就能够经过round-robin的均衡算法轮流地被接待。

对于监听进程/线程来讲,每次重用的套接字被称为监听桶(listener bucket),即每一个监听套接字都是一个监听桶。

以httpd的worker或event模型为例,假设目前有3个子进程,每一个子进程中都有一个监听线程和N个工做线程。

那么,在没有地址重用的状况下,各个监听线程是争抢式监听的。在某一时刻,这个监听套接字上只能有一个监听线程在监听(经过获取互斥锁mutex方式获取监听资格),当这个监听线程接收到请求后,让出监听的资格,因而其余监听线程去抢这个监听资格,并只有一个线程能够抢的到。以下图:

当使用了地址重用和端口重用技术,就能够为同一个addr+port绑定多个套接字。例以下图中是多使用一个监听桶时,有两个套接字,因而有两个监听线程能够同时进行监听,当某个监听线程接收到请求后,让出资格,让其余监听线程去争抢资格。

若是再多绑定一个套接字,那么这三个监听线程都不用让出监听资格,能够无限监听。以下图。

彷佛感受上去,性能很好,不只减轻了监听资格(互斥锁)的争抢,避免"饥饿问题",还能更高效地监听,并由于能够负载均衡,从而能够减轻监听线程的压力。但实际上,每一个监听线程的监听过程都是须要消耗CPU的,若是只有一核CPU,即便重用了也体现不出重用的优点,反而由于切换监听线程而下降性能。所以,要使用端口重用,必须考虑是否已将各监听进程/线程隔离在各自的cpu中,也就是说是否重用、重用几回都需考虑cpu的核数以及是否将进程与cpu相互绑定。

暂时就先写这么多了。

相关文章
相关标签/搜索