前言
相信大家对于TCP已经非常熟悉了,学习过计算机网络的同学对于它的连接和断开流程应该已经烂熟于心了吧。
那么Socket是什么?
Socket是应用层与TCP/IP协议簇通信的中间软件抽象层,它是一组接口。在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。
所以,我们无需深入理解tcp/udp协议,socket已经为我们封装好了,我们只需要遵循socket的规定去编程,写出的程序自然就是遵循tcp/udp标准的。
两种套接字
1. TCP协议栈维护者两个socket缓冲区:send buffer和recv buffer
要通过TCP连接发送出去的数据都先拷贝到send buffer中。这个过程可能是从用户空间进程的app buffer拷入,也可能是从内核的kernel buffer拷入。拷贝的过程是通过send()函数完成的,因此也被称为写数据。由于也可以使用write()函数写入数据,所以send buffer也被称为write buffer。不过,send()函数比write()函数更有效率。
最终数据是通过网卡流出的,所以需要将send buffer中的数据拷贝到网卡中。由于一端是内存,一端是网卡设备,可以直接使用DMA的方式进行拷贝,无需CPU的参与。也就是说,send buffer中的数据通过DMA的方式拷贝到网卡中,并通过网络传输给TCP连接的另一端:接收端。
当通过TCP连接接收数据时,数据肯定是先通过网卡流入的。然后同样通过DMA的方式拷贝到recv buffer中。再通过recv()函数将数据从recv buffer拷入到用户空间进程的app buffer中。
流程如下图:
2. 监听套接字和已连接套接字
1)监听套接字
在服务进程读取配置文件时,从配置文件中解析出要监听的地址、端口,然后通过socket()函数创建的,然后再通过bind()函数将这个监听套接字绑定到对应的地址和端口上。随后,进程/线程就可以通过listen()函数来监听这个端口(严格的来说监控这个监听套接字)
在Java中socket变成的代码实现:
// 返回的server可以认为是监听套接字
ServerSocket server = new ServerSocket(8888);
->
public ServerSocket(int port, int backlog, InetAddress bindAddr) throws IOException {
setImpl();
if (port < 0 || port > 0xFFFF)
throw new IllegalArgumentException(
"Port value out of range: " + port);
if (backlog < 1)
backlog = 50;
try {
bind(new InetSocketAddress(bindAddr, port), backlog);
} catch(SecurityException e) {
close();
throw e;
} catch(IOException e) {
close();
throw e;
}
}
->
public void bind(SocketAddress endpoint, int backlog) throws IOException {
if (isClosed())
throw new SocketException("Socket is closed");
if (!oldImpl && isBound())
throw new SocketException("Already bound");
if (endpoint == null)
endpoint = new InetSocketAddress(0);
if (!(endpoint instanceof InetSocketAddress))
throw new IllegalArgumentException("Unsupported address type");
InetSocketAddress epoint = (InetSocketAddress) endpoint;
if (epoint.isUnresolved())
throw new SocketException("Unresolved address");
if (backlog < 1)
backlog = 50;
try {
SecurityManager security = System.getSecurityManager();
if (security != null)
security.checkListen(epoint.getPort());
// 绑定地址端口
getImpl().bind(epoint.getAddress(), epoint.getPort());
// 进行监听
getImpl().listen(backlog);
bound = true;
} catch(SecurityException e) {
bound = false;
throw e;
} catch(IOException e) {
bound = false;
throw e;
}
}
在bind方法中创建了监听套接字。
2)已连接套接字
在监听到TCP连接请求并三次握手后,通过accept()函数返回的套接字,后续进程/线程就可以通过这个已连接套接字和客户端进行TCP通信。
为了区分socket()函数和accept()函数返回的两个套接字描述符,有些人使用listenfd和connfd分别表示监听套接字和已连接套接字,挺形象的,下文偶尔也这么使用。
在Java中socket编程体现:
//调用accept方法返回一个已连接套接字
Socket client = server.accept();
注意上面说的都是操作系统层面上的套接字,而不是抽象出来的Socket接口服务。
下面就来说明各种函数的作用,分析这些函数,也是在连接、断开连接的过程。
具体流程
流程图:
1. Socket()函数
socket()函数的作用就是生成一个用于通信的套接字文件描述符sockfd(socket() creates an endpoint for communication and returns a descriptor)。这个套接字描述符可以作为稍后bind()函数的绑定对象。
Socket socket = new Socket();
2. bind()函数
服务程序通过分析配置文件,从中解析出想要监听的地址和端口,在加上可以通过socket()函数生成的套接字sockfd,就可以使用bind()函数将这个套接字绑定到要监听的地址和端口组合“addr:port”上。绑定了端口的套接字可以作为listen()函数的监听对象。
绑定了地址和端口的套接字就有了源地址和源端口(对服务自身来说是源),再加上通过配置文件中指定的协议类型,五元组中就有了其中三个。即:
{protocal, src_addr, src_port}
但是,常见到有些服务程序可以配置监听多个地址、端口实现多实例。这实际上就是通过多次socket() + bind()系统调用生成并绑定多个套接字实现的。
InetSocketAddress address = new InetSocketAddress(host, port);
socket.bind(address);
3. listen()和connect()
listen()函数就是监听已经通过bind()绑定了addr+port的套接字的。监听之后,套接字就从CLOSE状态转变为LISTEN状态,于是这个套接字就可以对外提供TCP连接的窗口了。
而connect()函数则用于向某个已监听的套接字发起连接请求,也就是发起TCP的三次握手过程。从这里可以看出,连接请求方(如客户端)才会使用connect()函数,当然,在发起connect()之前,连接发起方也需要生成一个sockfd,且使用的很可能是绑定了随机端口的套接字(可以看一下自己计算机的端口使用情况,基本都是一些随机的端口)。既然connect()函数是向某个套接字发起连接的,自然在使用connect()函数时需要带上连接的目的地,即目标地址和目标端口,这正是服务端的监听套接字上绑定的地址和端口。同时,它还要带上自己的地址和端口,对于服务端来说,这就是连接请求的源地址和源端口。于是,【TCP连接的两端的套接字都已经成了五元组的完整格式。】
有关listen的代码前面已经给出
client.connect(address);
所有的步骤Java中socket类都有相对应的方法
4. 深入分析listen函数
再来细说listen()函数。如果监听了多个地址+端口,即需要监听多个套接字,那么此刻负责监听的进程/线程会采用select()
、poll()
的方式去轮询这些套接字(当然,也可以使用epoll()
模式),其实只监控一个套接字时,也是使用这些模式去轮询的,只不过select()或poll()所感兴趣的套接字描述符只有一个而已。
【下面的内容很重要!!!!!】
第一步:客户端发起连接
不管使用select()还是poll()模式(至于epoll的不同监控方式就无需多言了),在进程/线程(监听者)监听的过程中,它阻塞在select()或poll()上。直到有数据(SYN信息)写入到它所监听的sockfd中(即recv buffer)。
第二步:服务端发送syn+ack
内核被唤醒(注意不是app进程被唤醒,因为TCP三次握手和四次挥手是在【内核空间】由内核完成的,不涉及用户空间)并将SYN数据拷贝到kernel buffer中进行一番处理(比如判断SYN是否合理),并准备SYN+ACK数据,这个数据需要从kernel buffer中拷入send buffer中,再拷入网卡传送出去。这时会在连接未完成队列(syn queue)中为这个连接创建一个新项目,并设置为SYN_RECV状态(这个连接状态)。
第三步:客户端发出确认ack
然后再次使用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次挥手。
5. accept()方法
accpet()函数的作用是读取已完成连接队列中的第一项(读完就从队列中移除),并对此项生成一个用于后续连接的套接字描述符,假设使用connfd来表示。有了新的连接套接字,工作进程/线程(称其为工作者)就可以通过这个连接套接字和客户端进行数据传输,而前文所说的监听套接字(sockfd)则仍然被监听者监听。
6. TCP与Socket的关系
先明确一点,每个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的队列满了。
7. 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()了。
8. 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。