0%

套接字和文件描述符-上

[TOC]

套接字

概念

套接字 Socket=(IP地址:端口号),套接字的表示方法是点分十进制的IP地址后面写上端口号,中间用冒号或逗号隔开。每一个传输层连接唯一地被通信两端的两个端点(即两个套接字)所确定。基于linux一切皆文件的思想。一个套接字就是一个文件,sockfd(套接字文件描述符)就是一种文件描述符。

通过下面可知。服务端,在启动时,会创建一个套接字文件(监听套接字),系统返回一个套接字文件描述符。当服务端的accept()函数返回时,也就是当某一个客户端调用connect()时,这时。在服务端的主机上还会再生成一个套接字文件(已连接套接字),这个文件用于当前的服务端和客户端连接的信息传递。而服务端最开始创建的那个套接字文件,则是继续等待新的请求的到来。

从内核的角度来看,一个套接字就是通信的一个端点。一个连接由它两端的套接了地址唯一确定,这对套接字地址叫做套接字对(socket pair)。一个套接字对实际上是由两个已连接套接字构成

C 语言套接字编程

创建套接字-socket()函数

可以看到,文件描述符在编程中,就是用一个整数来表示!

int socket(int domain, int type, int protocol);

socket函数对应于普通文件的打开操作。普通文件的打开操作返回一个文件描述字,而socket()用于创建一个socket描述符(socket descriptor, 一般记为sockfd,它是一个int型的数值,相当于一个文件的引用),它唯一标识一个socket。

  • domain:即协议域,又称为协议族(family)。常用的协议族有,AF_INET、AF_INET6、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如AF_INET决定了要用ipv4地址(32位的)与端口号(16位的)的组合、AF_UNIX决定了要用一个绝对路径名作为地址。
  • type:指定socket类型。
  • protocol:故名思意,就是指定协议。常用的协议有,IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它们分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议。

绑定套接字-bind()函数

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

  • sockfd:即socket描述字,它是通过socket()函数创建了,唯一标识一个socket。bind()函数就是将给这个描述字绑定一个名字。
  • addr:一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址。这个地址结构根据地址创建socket时的地址协议族的不同而不同。端口号就是在这个参数中传入的
  • addrlen:对应的是地址的长度。

通常服务器在启动的时候都会绑定一个众所周知的地址(如ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动分配一个端口号和自身的ip地址组合。这就是为什么通常服务器端在listen之前会调用bind(),而客户端就不会调用,而是在connect()时由系统随机生成一个。

监听端口-listen()函数

int listen(int sockfd, int backlog);

  • sockfd:即socket描述字。
  • backlog: 设定等待连接的等待队列的长度。

刚开始理解listen函数会有一个误区,就是认为其操作是在等在一个新的connect的到来,其实不是这样的,真正等待connect的是accept操作,listen的操作就是当有较多的client发起connect时,server端不能及时的处理已经建立的连接,这时就会将connect连接放在等待队列中缓存起来。这个等待队列的长度有listen中的backlog参数来设定。listen和accept函数是服务器模式特有的函数,客户端不需要这个函数。当listen运行成功时,返回0;运行失败时,返回值位-1。listen的主要作用是缓冲队列

接受请求-accept()函数

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

  • sockfd:即socket描述字。

accept函数的第一个参数为服务器的socket描述字,第二个参数为指向struct sockaddr *的指针,用于返回客户端的协议地址,第三个参数为协议地址的长度。如果accpet成功(从阻塞状态返回),那么其返回值是由内核自动生成的一个全新的文件描述符,代表与返回客户的TCP连接。通过对该文件描述符操作,可以向client端发送和接收数据。同时之前socket创建的sockfd,则继续监听有没有新的连接到达本地端口。返回大于0的文件描述符则表示accept成功,否则失败。

客户端连接-connect()函数

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

客户端通过调用connect函数来建立与TCP服务器的连接。

connect函数的第一个参数即为

  • sockfd:客户端的socket描述字,
  • addr:服务器的socket地址
  • addrlen:socket地址的长度。

数据处理-read()、write()等函数

ssize_t read(int fd, void *buf, size_t count);

ssize_t write(int fd, const void *buf, size_t count);

read函数是负责从fd中读取内容.当读成功时,read返回实际所读的字节数,如果返回的值是0表示已经读到文件的结束了,小于0表示出现了错误。

write函数将buf中的nbytes字节内容写入文件描述符fd.成功时返回写的字节数。失败时返回-1,并设置errno变量。 在网络程序中,当我们向套接字文件描述符写时有俩种可能。1)write的返回值大于0,表示写了部分或者是全部的数据(还要检查返回值的大小和需要写入数据的大小是否一致)。2)返回的值小于0,此时出现了错误。我们要根据错误类型来处理。

连接关闭-close()函数

int close(int fd);

close一个TCP socket的缺省行为时把该socket标记为以关闭,然后立即返回到调用进程。该描述字不能再由调用进程使用,也就是说不能再作为read或write的第一个参数。

socket编程中的文件描述符-监听套接字和已连接套接字

客户端

1
2
3
4
5
6
7
8
9
10
socket()---->创建出 active_socket_fd (client_socket_fd)

# 如果不绑定端口,系统会随机绑定一个端口
bind()--->把active_socket_fd与ip,port绑定起来

connect()---> client_socket_fd 主动请求服务端的 listen_socket_fd

read()/write()---->读/写(client_socket_fd) socket io

close()---->关闭socket_fd

服务端

1
2
3
4
5
6
7
8
9
10
11
socket()---->创建出 active_socket_fd

bind()--->把active_socket_fd与ip,port绑定起来

listen()---->active_socket_fd--> listen_socket_fd 等待客户端的client_socket_fd来请求连接

accept()---->listen_socket_fd-->connec_socket_fd 把监听socket转变为连接socket,用于建立连接购的读写数据

read()/write()---->读/写(connec_socket_fd) socket io

close()---->关闭socket_fd

一开始socket函数, 不管在客户端还是在服务端, 创建的都是主动socket, 但是在服务端经过listen(), 后把其转变为listen_socket_fd(被动监听socket)。经过accept()后转变为connect_socket_fd(已连接socket)。在转变为connect_socket_fd之前, 都是同一个socket, 只不过是socket的状态改变了, 但是服务端经过accept()后返回的socket是新的socket, 用于连接后的read()/write()。

可以说,服务端有两类socket(实际中不止两个)

  • 监听socket,用于监听新的连接的到来
  • 已连接socket,用于和客户端交流

客户端只要一类socket

  • 已连接socket,用于和客户端交流

为什么服务端有两类套接字

有了listen_socket_fd和connect_socket_fd后, 就可以专门用一listen_socket_fd负责响应客户端的请求, 每次新的connect_socket_fd专门负责当前这次连接的数据交互。主要是性能更好

socket中TCP的三次握手建立连接详解

当客户端调用connect时,触发了连接请求,向服务器发送了SYN J包,这时connect进入阻塞状态;服务器监听到连接请求,即收到SYN J包,调用accept函数接收请求向客户端发送SYN K ,ACK J+1,这时accept进入阻塞状态;客户端收到服务器的SYN K ,ACK J+1之后,这时connect返回,并对SYN K进行确认;服务器收到ACK K+1时,accept返回,至此三次握手完毕,连接建立。

socket中TCP的四次握手释放连接详解

  • 某个应用进程首先调用close主动关闭连接,这时TCP发送一个FIN M;
  • 另一端接收到FIN M之后,执行被动关闭,对这个FIN进行确认。它的接收也作为文件结束符传递给应用进程,因为FIN的接收意味着应用进程在相应的连接上再也接收不到额外数据;
  • 一段时间之后,接收到文件结束符的应用进程调用close关闭它的socket。这导致它的TCP也发送一个FIN N;
  • 接收到这个FIN的源发送端TCP对它进行确认。

这样每个方向上都有一个FIN和ACK。

服务器可以主动释放连接吗?

答案肯定是可以的。从代码中可以看出,只要在服务端对已连接套接字调用close()方法就可以在服务端释放连接。这相当于上述socket中的4次握手中的后两步。

监听队列(listen queue)和溢出

int listen(int sockfd, int backlog);

backlog是一个参数,当用户没有足够快地调用accept(2)时,它控制内核将为新连接保留多少内存。

例如,假设您有一个阻塞的单线程HTTP服务器,每个HTTP请求大约需要100毫秒。在这种情况下,HTTP服务器将花费100毫秒处理每个请求,然后才能再次调用accept(2)。这意味着在最多10个 rps 的情况下不会有排队现象。如果内核中有10个以上的 rps,则有两个选择。

  • 内核的第一个选择是根本不接受连接。例如,内核可以拒绝对传入的SYN包进行ACK。更常见的情况是,内核将完成TCP三次握手,然后使用RST终止连接。不管怎样,结果都是一样的:如果连接被拒绝,就不需要分配接收或写入缓冲区。
  • 内核的第二个选择是接受连接并为其分配一个套接字结构(包括接收/写入缓冲区),然后将套接字对象排队以备以后使用。下次用户调用accept(2)将立即获得已分配的套接字, 而不是阻塞系统调用。

内核将会对新连接进行排队,但只是一定数量的连接。内核将排队的连接数量由listen(2)的backlog参数控制。通常此值设置为相对较小的值。在Linux上,socket.h 将 somaxconn 的值设置为128。

当监听队列填满时,新连接会被拒绝。这称为监听队列溢出。

进程监听队列检查

1
2
3
4
5
6
7
8
# 检查在5000端口上的连接
netstat -antp | grep 5000

# 只看处于listen状态的tcp套接字
ss -tl

-t:只显示tcp套接字;
-l:显示处于监听状态的套接字;

ss指令中的recv-q和send-q。LISTEN 状态: Recv-Q 表示的当前等待服务端调用 accept 完成三次握手的 listen backlog 数值,也就是说,当客户端通过 connect() 去连接正在 listen() 的服务端时,这些连接会一直处于这个 queue 里面直到被服务端 accept();Send-Q 表示的则是最大的 listen backlog 数值,这就就是上面提到的 min(backlog, somaxconn) 的值。

所以线上的一个常见的问题就是(特别是想tornado这种非线程编程模型的框架),连接被拒绝。这个时候就果断的使用ss -tl查看一下进程的等待队列是否已经满了。

读语义和写语义

读语义

读语义就是将内核接受缓冲区中的数据删除,并将该数据复制到进程调用read(2)函数时提供的缓冲区。

如果接收缓冲区为空,并且用户调用read(2),则系统调用将被阻塞,直到数据可用。如果接收缓冲区是非空的,并且用户调用read(2),系统调用将立即返回这些可用的数据。如果读取队列中准备好的数据量小于用户提供的缓冲区的大小,则可能发生部分读取。调用方可以通过检查read(2)的返回值来检测到这一点。如果接收缓冲区已满,而TCP连接的另一端尝试发送更多的数据,内核将拒绝对数据包进行ACK。

写语义

写语义就是将用户提供的缓冲区中的数据复制到内核写入队列中。

如果写入队列未满,并且用户调用写入,则系统调用将成功。如果写入队列有足够的空间,则将复制所有数据。如果写入队列只有部分数据的空间,那么将发生部分写入,并且只有部分数据将被复制到缓冲区。调用方通过检查write(2)的返回值来检查这一点。如果写入队列已满,并且用户调用写入write(2)),则系统调用将被阻塞。

走向互联网

我们平时用到的套接字其实只是一个引用(一个对象ID),这个套接字对象实际上是放在操作系统内核中。这个套接字对象内部有两个重要的缓冲结构,一个是读缓冲(read buffer),一个是写缓冲(write buffer),它们都是有限大小的数组结构。

当我们对客户端的socket写入字节数组时(序列化后的请求消息对象req),是将字节数组拷贝到内核区套接字对象的write buffer中,内核网络模块会有单独的线程负责不停地将write buffer的数据拷贝到网卡硬件,网卡硬件再将数据送到网线,经过一些列路由器交换机,最终送达服务器的网卡硬件中。

同样,服务器内核的网络模块也会有单独的线程不停地将收到的数据拷贝到套接字的read buffer中等待用户层来读取。最终服务器的用户进程通过socket引用的read方法将read buffer中的数据拷贝到用户程序内存中进行反序列化成请求对象进行处理。然后服务器将处理后的响应对象走一个相反的流程发送给客户端,这里就不再具体描述。

简单动画

如何查看一个进程的所有文件描述符

1
2
3
4
5
6
7
8
9
10
# 1. 获取进程id(ps -ef | grep ...),如我这里是 22380

# 2. 查看进程级别限制, 实际上这里还能查到进程数限制等
cat /proc/22380/limits | grep "open file"

# 3. 查看进程使用的文件描述符
ls /proc/22380/fd

# 4. 计数
ls /proc/22380/fd| wc -l

新增一个连接后会,新建一个socket文件(也就多了一个文件描述符)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# ./server 启动后,只会有一个负责监听额socket文件
dr-x------ 2 root root 0 Feb 1 13:37 ./
dr-xr-xr-x 9 root root 0 Feb 1 13:37 ../
lrwx------ 1 root root 64 Feb 1 13:37 0 -> /dev/pts/3
lrwx------ 1 root root 64 Feb 1 13:37 1 -> /dev/pts/3
lrwx------ 1 root root 64 Feb 1 13:37 2 -> /dev/pts/3
lrwx------ 1 root root 64 Feb 1 13:37 3 -> socket:[12004746]
# 执行 ./client后,新增了一个socket文件
root@upupup:/data/pypath/cloud-fly# ll /proc/19011/fd
total 0
dr-x------ 2 root root 0 Feb 1 13:37 ./
dr-xr-xr-x 9 root root 0 Feb 1 13:37 ../
lrwx------ 1 root root 64 Feb 1 13:37 0 -> /dev/pts/3
lrwx------ 1 root root 64 Feb 1 13:37 1 -> /dev/pts/3
lrwx------ 1 root root 64 Feb 1 13:37 2 -> /dev/pts/3
lrwx------ 1 root root 64 Feb 1 13:37 3 -> socket:[12004746]
lrwx------ 1 root root 64 Feb 1 13:37 4 -> socket:[12004747]

附录

go-science/example/cpp

参考

C语言之网络编程(服务器和客户端):https://blog.csdn.net/zh0314/article/details/77387162

Linux Socket编程(不限Linux): https://www.cnblogs.com/skynet/archive/2010/12/12/1903949.html

监听套接字与已连接套接字:https://blog.csdn.net/lihao21/article/details/64951446

为什么有监听socket和连接socket,为什么产生两个socket:https://www.cnblogs.com/liangjf/p/9900928.html

ss命令和Recv-Q和Send-Q状态:https://www.cnblogs.com/leezhxing/p/5329786.html

当我们在读写Socket时,我们究竟在读写什么?:https://juejin.im/post/5b344ad6e51d4558892eeb46