Socket
socket 在所有的网络操作系统中都是必不可少的,而且在所有的网络应用程序中也是必不可少的。它是网络通信中应用程序对应的进程和网络协议之间的接口之间的接口。 本文将介绍其使用和内核实现原理,参考 Linux 内核版本为 v5.4。
Background
socket 在网络系统中的作用是:
- socket 位于协议之上,屏蔽了不同网络协议之间的差异;
- socket 是网络编程的入口,它提供了大量的系统调用,构成了网络程序的主体;
- 在 Linux 系统中,socket 属于文件系统的一部分,网络通信可以被看作是对文件的读取,使得我们对网络的控制和对文件的控制一样方便。
结构
无论是用 socket 操作 TCP,还是 UDP,我们首先都要调用 socket 函数。
|
|
socket 函数用于创建一个 socket 的文件描述符,唯一标识一个 socket。我们把它叫作文件描述符,因为在内核中,我们会创建类似文件系统的数据结构,并且后续的操作都有用到它。
在创建 socket 的时候,有三个参数:
-
family
表示地址族。不是所有的 Socket 都要通过 IP 进行通信,还有其他的通信方式。例如,下面的定义中,domain sockets 就是通过本地文件进行通信的,不需要 IP 地址。只不过,通过 IP 地址只是最常用的模式,所以我们这里着重分析这种模式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17/* Supported address families. */ #define AF_UNSPEC 0 #define AF_UNIX 1 /* Unix domain sockets */ #define AF_LOCAL 1 /* POSIX name for AF_UNIX */ #define AF_INET 2 /* Internet IP Protocol */ #define AF_AX25 3 /* Amateur Radio AX.25 */ #define AF_IPX 4 /* Novell IPX */ #define AF_APPLETALK 5 /* AppleTalk DDP */ #define AF_NETROM 6 /* Amateur Radio NET/ROM */ #define AF_BRIDGE 7 /* Multiprotocol bridge */ #define AF_ATMPVC 8 /* ATM PVCs */ #define AF_X25 9 /* Reserved for X.25 project */ #define AF_INET6 10 /* IP version 6 */ // ... #define AF_XDP 44 /* XDP sockets */ #define AF_MAX 45 /* For now.. */对应的也有 Protocol Famliy
1 2 3 4 5 6 7 8 9/* Protocol families, same as address families. */ #define PF_UNSPEC AF_UNSPEC #define PF_UNIX AF_UNIX #define PF_LOCAL AF_LOCAL #define PF_INET AF_INET #define PF_AX25 AF_AX25 #define PF_IPX AF_IPX #define PF_APPLETALK AF_APPLETALK // ... -
type
常用的 Socket 类型有三种,分别是 SOCK_STREAM、SOCK_DGRAM 和 SOCK_RAW。
1 2 3 4 5 6 7 8 9 10 11enum sock_type { SOCK_STREAM = 1, // stream (connection) socket SOCK_DGRAM = 2, // datagram (conn.less) socket SOCK_RAW = 3, // raw socket SOCK_RDM = 4, // reliably-delivered message SOCK_SEQPACKET = 5, // sequential packet socket SOCK_DCCP = 6, // Datagram Congestion Control Protocol socket SOCK_PACKET = 10, // linux specific way of getting packets at the dev level. // For writing rarp and other similar things on the user level. }; #define SOCK_MAX (SOCK_PACKET + 1)- SOCK_STREAM 是面向数据流的,协议 IPPROTO_TCP 属于这种类型。
- SOCK_DGRAM 是面向数据报的,协议 IPPROTO_UDP 属于这种类型。如果在内核里面看的话,IPPROTO_ICMP 也属于这种类型。
- SOCK_RAW 是原始的 IP 包,IPPROTO_IP 属于这种类型。
这一节,我们重点看 SOCK_STREAM 类型和 IPPROTO_TCP 协议。
-
protocol
第三个参数是 protocol,是协议。协议数目是比较多的,也就是说,多个协议会属于同一种类型。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56#if __UAPI_DEF_IN_IPPROTO /* Standard well-defined IP protocols. */ enum { IPPROTO_IP = 0, /* Dummy protocol for TCP */ #define IPPROTO_IP IPPROTO_IP IPPROTO_ICMP = 1, /* Internet Control Message Protocol */ #define IPPROTO_ICMP IPPROTO_ICMP IPPROTO_IGMP = 2, /* Internet Group Management Protocol */ #define IPPROTO_IGMP IPPROTO_IGMP IPPROTO_IPIP = 4, /* IPIP tunnels (older KA9Q tunnels use 94) */ #define IPPROTO_IPIP IPPROTO_IPIP IPPROTO_TCP = 6, /* Transmission Control Protocol */ #define IPPROTO_TCP IPPROTO_TCP IPPROTO_EGP = 8, /* Exterior Gateway Protocol */ #define IPPROTO_EGP IPPROTO_EGP IPPROTO_PUP = 12, /* PUP protocol */ #define IPPROTO_PUP IPPROTO_PUP IPPROTO_UDP = 17, /* User Datagram Protocol */ #define IPPROTO_UDP IPPROTO_UDP IPPROTO_IDP = 22, /* XNS IDP protocol */ #define IPPROTO_IDP IPPROTO_IDP IPPROTO_TP = 29, /* SO Transport Protocol Class 4 */ #define IPPROTO_TP IPPROTO_TP IPPROTO_DCCP = 33, /* Datagram Congestion Control Protocol */ #define IPPROTO_DCCP IPPROTO_DCCP IPPROTO_IPV6 = 41, /* IPv6-in-IPv4 tunnelling */ #define IPPROTO_IPV6 IPPROTO_IPV6 IPPROTO_RSVP = 46, /* RSVP Protocol */ #define IPPROTO_RSVP IPPROTO_RSVP IPPROTO_GRE = 47, /* Cisco GRE tunnels (rfc 1701,1702) */ #define IPPROTO_GRE IPPROTO_GRE IPPROTO_ESP = 50, /* Encapsulation Security Payload protocol */ #define IPPROTO_ESP IPPROTO_ESP IPPROTO_AH = 51, /* Authentication Header protocol */ #define IPPROTO_AH IPPROTO_AH IPPROTO_MTP = 92, /* Multicast Transport Protocol */ #define IPPROTO_MTP IPPROTO_MTP IPPROTO_BEETPH = 94, /* IP option pseudo header for BEET */ #define IPPROTO_BEETPH IPPROTO_BEETPH IPPROTO_ENCAP = 98, /* Encapsulation Header */ #define IPPROTO_ENCAP IPPROTO_ENCAP IPPROTO_PIM = 103, /* Protocol Independent Multicast */ #define IPPROTO_PIM IPPROTO_PIM IPPROTO_COMP = 108, /* Compression Header Protocol */ #define IPPROTO_COMP IPPROTO_COMP IPPROTO_SCTP = 132, /* Stream Control Transport Protocol */ #define IPPROTO_SCTP IPPROTO_SCTP IPPROTO_UDPLITE = 136, /* UDP-Lite (RFC 3828) */ #define IPPROTO_UDPLITE IPPROTO_UDPLITE IPPROTO_MPLS = 137, /* MPLS in IP (RFC 4023) */ #define IPPROTO_MPLS IPPROTO_MPLS IPPROTO_RAW = 255, /* Raw IP packets */ #define IPPROTO_RAW IPPROTO_RAW IPPROTO_MAX }; #endif并不是上面的 type 和 protocol 可以随意组合的,如
SOCK_STREAM不可以跟IPPROTO_UDP组合。当 protocol 为 0 时,会自动选择 type 类型对应的默认协议。
为了管理 family、type、protocol 这三个分类层次,内核会创建对应的数据结构。
UDP
TCP
三次握手建立连接
Socket vs Sock
内核中 socket 有两个数据结构,一个是 socket,另一个是 sock:
- socket 是 general BSD socket,是应用程序和 4 层协议之间的接口,屏蔽掉了相关的 4 层协议部分
- sock 是内核中保存 socket 所需要使用的相关的 4 层协议的信息
- socket 和 sock 这两个结构都有保存对方的指针,因此可以很容易的存取对方
同样的,在 socket 和 sock 层分别有 proto_ops 和 proto 两个数据结构:
- socket 的 ops 域保存了对于不同的 socket 类型的操作函数
- sock 中有一个 sk_common 保存了一个 skc_prot 域保存的是相应的协议簇的操作函数的集合
- proto 相当于对 proto_ops 的一层封装,最终会在 proto 中调用 proto_ops
内核调用路径
内核在调用相关操作都是
- 直接调用 socket 的 ops 域
- 然后在 ops 域中调用相应的 sock 结构体中的 sock_common 域的 skc_prot 的操作集中的相对应的函数
举个例子,假设现在我们使用 tcp 协议然后调用 bind 方法,内核会先调用 sys_bind 方法(属于 socket 的操作集):
|
|
可以看到它调用的是 ops 域的 bind 方法.而这时我们的 ops 域是 inet_stream_ops来看它的 bind 方法(属于 sock 的操作集):
|
|
它最终调用的是 sock 结构的 sk_prot 域(也就是 sock_common 的 skc_prot 域) 的 bind 方法,而此时我们的 skc_prot 的值是 tcp_prot,因此最终会调用 tcp_prot 的 bind 方法。对于 bind 而言,因为没有 bind,所以还是调用的 __inet_bind。
下面就是示意图:
socket
|
|
proto_ops
|
|
socket state
|
|
sock
sock_common
|
|
sock
|
|
proto
很重要的是其连接着对应的 proto 操作集,在操作集中维护着对应的 hashinfo,hashinfo 存储着 sock、端口等重要信息
|
|
inet_sock
inet_sock 是 INET 域的 socket 表示,是对 struct sock 的一个扩展,提供 INET 域的一些属性,如 TTL,组播列表,IP 地址,端口等;
|
|
raw_sock
它是 RAW 协议的一个 socket 表示,是对 struct inet_sock 的扩展,它要处理与 ICMP 相关的内容;
|
|
udp_sock
它是 UDP 协议的 socket 表示,是对 struct inet_sock 的扩展;
|
|
inet_connection_sock
inet_connection_sock 是所有 面向连接 的 socket 表示,是对 struct inet_sock 的扩展;它的第一个域就是 inet_sock。主要维护了与其绑定的端口结构,以及监听等状态过程中的信息。
|
|
tcp_sock
tcp_sock 是 TCP 协议的 socket 表示,是对 struct inet_connection_sock 的扩展,主要增加滑动窗口,拥塞控制一些 TCP 专用属性;它的第一个域就是 inet_connection_sock 。
|
|
Socket
socket 系统调用
我们从 socket 系统调用开始:
|
|
这里面的代码比较容易看懂,socket 系统调用会调用 sock_create 创建一个 struct socket 结构,然后通过 sock_map_fd 和文件描述符对应起来。
sock_create
接下来,我们打开 sock_create 函数看一下,可以看到它调用了 __sock_create,添加了 network namespace 和 是否是 kern 的参数。
|
|
这里主要做了两件事:
- 调用
sock_alloc分配了一个struct socket结构,并创建了该 socket 对应的 inode - 根据
family参数拿到了对应的net_proto_family,并调用pf->create
net_proto_family 初始化
这里解释下 net_proto_family:linux 网络子系统有一个 net_families 数组,我们能够以 family 参数为下标,找到对应的 struct net_proto_family,每个 net_proto_family 都有一个 create 函数:
|
|
每一种地址族都有自己的 net_proto_family,IP 地址族的 net_proto_family 定义如下,里面最重要的 create 函数指向 inet_create。
|
|
inet_family 是在 inet_init 中注册到 net_families 中的:
|
|
sock_register
查看 sock_register 的实现,实际上就是向 net_families 这个数组插入对应的协议实现:
|
|
注册 inetsw[]
在 inet_init 里面接下来初始化的是 inetsw 和 inetsw_array。
这里的 inetsw 也是一个数组,type 作为下标,里面的内容是 struct inet_protosw,是协议,也即 inetsw 数组对于每个类型有一项,这一项里面是属于这个类型的协议。
|
|
inetsw 数组是在系统初始化的时候初始化的,就像下面代码里面实现的一样。
首先,一个循环会将 inetsw 数组的每一项,都初始化为一个链表。咱们前面说了,一个 type 类型会包含多个 protocol,因而我们需要一个链表。接下来一个循环,是将 inetsw_array 注册到 inetsw 数组里面去。inetsw_array 的定义如下,这个数组里面的内容很重要,后面会用到它们。
|
|
sock_alloc
初始化 socket 对象,主要与文件系统打交道:
在文件系统中分配 inode
- 通过
new_inode_pseudo在 socket 文件系统中创建新的 inode- 其中 sock_mnt 为 socks 的根节点,是在内核初始化安装 socket 文件系统时赋值的
- mnt_sb 是该文件系统安装点的超级块对象的指针
|
|
根据 inode 取得 socket 对象
- 由于创建 inode 是文件系统的通用逻辑,因此其返回值是 inode 对象的指针;
- 但这里在创建 socket 的 inode 后,需要根据 inode 得到 socket 对象;
- 内联函数
SOCKET_I由此而来:
|
|
回到 sock_alloc 函数,SOCKET_I 根据 inode 取得 socket 变量后,记录当前进程的一些信息
- 如 fsuid, fsgid,并增加 sockets_in_use 的值(该变量表示创建 socket 的个数);
- 设置 inode 的 imode 为
S_IFSOCK - 设置 inode 的 i_op 为 sockfs_inode_ops
|
|
inet_create
我们回到函数 __sock_create。接下来,在这里面,这个 inet_create 会被调用。
|
|
设置 socket 状态为 SS_UNCONNECTED
|
|
找到 type/protocol 对应的 inet_protosw
在 inet_create 中,我们先会看到一个循环 list_for_each_entry_rcu。在这里,socket 的第二个参数 type 开始起作用。因为循环查看的是 inetsw[sock->type]。
我们回到 inet_create 的 list_for_each_entry_rcu 循环中。到这里就好理解了:
- 这是在 inetsw 数组中,根据 type 找到属于这个类型的列表
- 依次比较列表中的
struct inet_protosw的 protocol 是不是用户指定的 protocol;- 如果是,就得到了符合用户指定的
family->type->protocol的struct inet_protosw *answer对象
- 如果是,就得到了符合用户指定的
接下来,struct socket *sock 的 ops 成员变量,被赋值为 answer 的 ops。对于 TCP 来讲,就是 inet_stream_ops。后面任何用户对于这个 socket 的操作,都是通过 inet_stream_ops 进行的。
|
|
初始化 socket
|
|
结合例子,socket 变量的 ops 指向 inet_stream_ops 结构体变量;
分配 sock 结构体变量 sk_alloc
接下来,我们创建一个 struct sock *sk 对象。这里比较让人困惑。socket 和 sock 看起来几乎一样,容易让人混淆,这里需要说明一下,socket 是用于负责对上给用户提供接口,并且和文件系统关联。而 sock 负责向下对接内核网络协议栈。前面代码中已经看到,我们通过 sock_alloc 来初始化 socket 对象,通过 sk_alloc 来初始化 sock 对象。
|
|
在 sk_alloc 函数中,struct inet_protosw *answer 结构的 tcp_prot 赋值给了 struct sock *sk 的 sk_prot 成员。tcp_prot 的定义如下,里面定义了很多的函数,都是 sock 之下内核协议栈的动作。
|
|
这里创建了 struct sock,主要用于初始化各种网络协议栈相关的对象。
|
|
建立 socket 与 sock 的关系 sock_init_data
在 inet_create 函数中,接下来创建一个 struct inet_sock 结构,这个结构一开始就是 struct sock,然后扩展了一些其他的信息,剩下的代码就填充这些信息。这一幕我们会经常看到,将一个结构放在另一个结构的开始位置,然后扩展一些成员,通过对于指针的强制类型转换,来访问这些成员。
|
|
这里为什么能直接将 sock 结构体变量强制转化为 inet_sock 结构体变量呢?只有一种可能,那就是在分配 sock 结构体变量时,真正分配的是 inet_sock 或是其他结构体。
我们回到分配 sock 结构体的那块代码(net/core/sock.c):
|
|
上面的代码在分配 sock 结构体时,有两种途径:
- 一是从 tcp 专用高速缓存中分配,这种情况下在初始化高速缓存时,指定了结构体大小为
prot->obj_size - 二是从内存直接分配,这种情况下也有指定大小为 prot->obj_sizee
根据这点,我们看下 tcp_prot 变量中的 obj_size:
|
|
也就是说,分配的真实结构体是 tcp_sock;由于 tcp_sock、inet_connection_sock、inet_sock、sock 之间均为 0 处偏移量,因此可以直接将 tcp_sock 直接强制转化为 inet_sock;这几个结构体间的关系如下:
创建完 sock 变量之后,便是初始化 sock 结构体,并建立 sock 与 socket 之间的引用关系;调用函数为 sock_init_data()
该函数主要工作是:
- 初始化 sock 结构的缓冲区、队列等;
- 初始化 sock 结构的状态为 TCP_CLOSE;
- 建立 socket 与 sock 结构的相互引用关系;
|
|
使用 tcp 协议初始化 sock
inet_create() 函数最后,通过相应的协议来初始化 sock 结构:
|
|
对 TCP 协议而言,其 init 函数是 tcp_v4_init_sock,它主要是对 tcp_sock 和 inet_connection_sock 进行一些初始化。
tcp_v4_init_sock
|
|
tcp_init_sock
|
|
sock_map_fd
创建好与 socket 相关的结构后,需要与文件系统关联,详见 sock_map_fd() 函数:
- 申请文件描述符,并分配 file 结构和目录项结构;
- 关联 socket 相关的文件操作函数表和目录项操作函数表;
- 将 file->private_date 指向 socket;
关于 VFS,可以参考 VFS
|
|
get_unused_fd_flags
从当前进程的 files 结构分配一个 unused fd
|
|
sock_alloc_file
创建一个 file 对象,内核通过把 socket 指针赋值给 file 的 private_data。这样一来,就可以通过 fd,在 fdtable 中得到 file 对象,然后得到 socket 对象:
|
|
这里的 socket_file_ops 定义如下:
|
|
经过这个 赋值之后,我们直接对 socket 的 fd 进行 read/write 等操作,就会进入到对应协议处理。以 read 为例,这里会调用 sock_recvmsg ,最终就调用到对应协议的 recvmsg:
|
|
fd_install
为当前进程构建 fd 与 file 的映射关系:
|
|
socket 调用总结
最终来总结一下:
- 内核中,socket 是作为一个伪文件系统来实现的,它在初始化时注册到内核
- 每个进程的 files_struct 域保存了所有的句柄,包括 socket 的.
- 一般的文件操作,内核直接调用 vfs 层的方法,然后会自动调用 socket 实现的相关方法
- 内核通过 inode 结构的 imode 域就可以知道当前的句柄所关联的是不是 socket 类型
- 这时遇到 socket 独有的操作,就通过 containof 方法,来计算出 socket 的地址,从而就可以进行相关的操作
Bind
地址结构
struct sockaddr
struct sockaddr 其实相当于一个基类的地址结构,其他的结构都能够直接转到 sockaddr。举个例子比如当 sa_family 为 PF_INET 时,sa_data 就包含了端口号和 ip 地址:
|
|
struct sockaddr_in
sockaddr_in 表示了所有的 ipv4 的地址结构,即代表 AF_INET 域的地址,可以看到它相当于 sockaddr 的一个子类。
|
|
struct sockaddr_storage
这里还有一个内核比较新的地址结构 sockaddr_storage,他可以容纳所有类型的套接口结构,比如 ipv4,ipv6 等。相比于 sockaddr,它是强制对齐的。
|
|
存储数据结构
inet_hashinfo
第一个是 inet_hashinfo,它主要用来管理 tcp 的 bind hash bucket:
|
|
在这个结构体中,有几个元素:
- ehash:e 指的是 establish,管理除了 LISTEN 状态的所有 socket 的 hash 表
- 类似于 C++中的
unordered_map<Key, sock*> - key 是
{source_ip, destination_ip, source_port, destination_port} sock*可以指向 tcp_request_sock、tcp_sock、tcp_timewait_sock 之一
- 类似于 C++中的
- bhash: b 指的是 bind,负责端口分配
- 类似于 C++中的
map<uint16_t, list<tcp_sock*>>其中 list 对应inet_bind_bucket
- 类似于 C++中的
- listening_hash:负责侦听(listening) socket,bucket 数量是在编译内核时确定,通常是 32 个。
tcp_hashinfo 是 inet_hashinfo 结构体的实例:
|
|
tcp_hashinfo 在 tcp_init 中被初始化:
|
|
然后 tcp_hashinfo 会被赋值给 tcp_proto 和 sock 的 sk_prot 域. 其具体的实现在 /net/ipv4/tcp.c 中。
inet_ehash_bucket
struct inet_ehash_bucket 管理所有的 tcp 状态在 TCP_ESTABLISHED 和 TCP_CLOSE 之间的 sock:
|
|
这里的 bucket 里面是 hlist_nulls_head 是有可能会碰到两个 hash 值一样的 socket,以链表形式连起来(这也就是 hash table)
在 tcp_init 中,我们会初始化 ehash,这里申请了一个系统级较大的 hash 表,hash 表有 thash_entries 个 bucket,它的大小在内核启动的时候确定(有参数可以配置),不会更改(即不会 rehash):
|
|
参考内核日志:
|
|
参考 alloc_large_system_hash 函数声明:
|
|
inet_bind_hashbucket
inet_bind_hashbucket 是哈希桶结构, lock 成员是用于操作时对桶进行加锁, chain 成员是相同哈希值的节点的链表。结构如下,存储了所有的端口的信息:
|
|
在 tcp_init 中,我们会初始化 bhash,这里申请了一个系统级较大的 hash 表,它的大小在内核启动的时候确定(有参数可以配置),不会更改(即不会 rehash):
|
|
inet_bind_bucket
前面提到的哈希桶结构中的 chain 链表中的每个节点,其宿主结构体是 inet_bind_bucket ,该结构体通过成员 node 链入链表;
|
|
用户编程实例
|
|
bind 实现
|
|
查找 socket 结构
根据 fd 取得关联的 socket 结构:
|
|
将用户空间地址结构复制到内核空间
|
|
调用 bind 函数 inet_bind
|
|
可以看到最终时调用了 __inet_bind 函数
地址类型检查
|
|
这里的 inet_addr_type_table 用于检查该地址所属的类型:
- 广播地址
- 本地地址
- 多播地址
然后再做对应判断
端口范围的检查
- 如果端口号小于
inet_prot_sock,也即是 1024 必须得有 root 权限,如果没有则退出 - capable 就是用来判断权限的,如果没有
CAP_NET_BIND_SERVICE则退出
|
|
设置发送源地址和接收源地址
- 这里先检查 sock 的状态,如果不是 TCP_CLOSE 或端口为 0,则出错返回(这里也映射到创建 socket 时要将 sock 结构体变量的状态设置为 TCP_CLOSE 上了)
- 如果地址类型是多播或广播,则源地址设置为 0,而接收地址为设置的 ip 地址
|
|
检查端口是否被占用 inet_csk_get_port
|
|
用于检查端口的函数为 sk->sk_prot->get_port(sk, snum),在 tcp 中被实例化为 inet_csk_get_port,这里我先来介绍下 inet_csk_get_port 的流程:
- 当绑定的 port 为 0 时,也即是需要 kernel 来分配一个新的 port
- 首先得到系统的 port 范围
- 随机分配一个 port
- 从 bhash 中得到当前随机分配的端口的链表(也就是 inet_bind_bucket 链表)
- 遍历这个链表(链表为空的话,也说明这个 port 没有被使用),如果这个端口已经被使用,则将端口号加一,继续循环,直到找到当前没有被使用的 port,也就是没有在 bhash 中 存在的 port
- 新建一个 inet_bind_bucket,并插入到 bhash 中.
- 当指定 port
- 从 bhash 中根据 hash 值 (port 计算的) 取得当前指定端口对应的 inet_bind_bucket 结构
- 如果 bhash 中存在,则说明这个端口已经在使用,因此需要判断这个端口是否允许被 reuse
- 如果不存在,则步骤和上面的第 5 步一样
|
|
|
|
初始化目标地址和目的地址
由于绑定时不知道目的地址的信息,所以置为 0
|
|
Listen
数据结构
request_sock_queue
前面提到,inet_connection_sock 是所有 面向连接 的 socket 表示,是对 struct inet_sock 的扩展;它的第一个域就是 inet_sock。主要维护了与其绑定的端口结构,以及监听等状态过程中的信息。它包含了一个 icsk_accept_queue 的域,这个域是一个 request_sock_queue 类型。
每个需要 listen 的 sock 都需要维护一个 request_sock_queue 结构,主要保存了与自己连接的半连接信息。
request_sock_queue 也就表示一个 request_sock 队列。这里我们知道,tcp 中分为
- 半连接队列:处于 SYN_RECVD 状态,刚接到 syn,等待三次握手完成
- 已完成连接队列:处于 established 状态,已经完成三次握手,等待 accept 来读取
在连接建立过程中:
- 当当 syn 信号到来时,即处于半连接状态时,我们都会新建一个
request_sock结构,并将它加入到listen_sock的request_sock hash表中 - 当当 3 次握手完毕后,即处于以完成连接状态时,将它放入到
request_sock_queue的rskq_accept_head和rskq_accept_tail队列中 - 当 accept 的时候,就直接从这个队列中读取,并且将
request_sock结构释放,然后在 BSD 层新建一个 socket 结构,并将它和接收端新建的子 sock 结构关联起来
|
|
listen_sock
listen_sock 维护了一个 sock 的半连接队列的信息,其中的 syn_table 中是连接该 sock 的所有半连接队列的 hash 数组。
新建立的 request_sock 就存放在 listen_sock 结构的 syn_table 中。这是一个哈希数组,总共有 nr_table_entries 项。实际上在分配内存时,分配的大小是 TCP_SYNQ_HSIZE(512)项。成员 max_qlen_log 以 2 的对数的形式表示 request_sock 队列的最大值。哈希表有 512 项,但队列的最大值的取值是 1024。即 max_qlen_log 的值为 10。qlen 是队列的当前长度。hash_rnd 是一个随机数,计算哈希值用。
我们需要知道每一个 SYN 请求都会新建一个 request_sock 结构,并将它加入到 listen_sock 的 syn_table 哈希表中,然后接收端会发送一个 SYN/ACK 段给 SYN 请求端,当 SYN 请求端将 3 次握手的最后一个 ACK 发送给接收端后,并且接收端判断 ACK 正确,则将 request_sock 从 syn_table 哈希表中删除,将 request_sock 加入到 request_sock_queue 的 rskq_accept_head 和 rskq_accept_tail 队列中,最后的 accept 系统调用不过是判断 accept 队列是否存在完成 3 次请求的 request_sock,从这个队列中将 request_sock 结构释放,然后在 BSD 层新建一个 socket 结构,并将它和接收端新建的子 sock 结构关联起来。
request_sock
request_sock 保存了 tcp 双方传输所必需的一些域,比如窗口大小、对端速率、对端数据包序列号等等这些值.
|
|
inet_request_sock
struct inet_request_sock 是 request_sock 的扩展,其定义如下
|
|
tcp_request_sock
增加了对于接收和发送的 ISN(初始序列号)等的维护。
|
|
Listen 实现
Listen 库函数调用的主要工作可以分为以下几步:
- 根据 socket 文件描述符找到内核中对应的 socket 结构体变量;
- 设置 socket 的状态并初始化等待连接队列;
- 将 socket 放入 inet_hashinfo 的 listen 哈希表中;
这里还有一个概念那就是 backlog 是用户通过函数调用传入的。在 linux 中, backlog 的大小指的是可支持连接队列的大小,而可支持的半开连接的大小一般是和 backlog差不多,而半开连接队列的最大长度是根据 backlog 计算的。
|
|
inet_listen
|
|
上面的代码中,有点值得注意的是,当 sock 状态已经是 TCP_LISTEN 时,也可以继续调用 listen() 库函数,其作用是设置 sock 的最大并发连接请求数;
inet_csk_listen_start
它的主要工作是新分配一个 listen socket,将它加入到 inet_connection_sock 的 icsk_accept_queue域的 listen_opt 中,然后对当前使用端口进行判断,最终返回:
|
|
request_sock_queue 的初始化 reqsk_queue_alloc
先看下 reqsk_queue_alloc() 的源代码:
|
|
整个过程中,先计算 request_sock 的大小并申请空间,然后初始化 request_sock_queue 的相应成员的值;
端口注册
前面提到管理 socket 的哈希表结构 inet_hashinfo ,其中的成员 listening_hash 用于存放处于 TCP_LISTEN 状态的 sock ;
当 socket 通过 listen() 调用完成等待连接队列的初始化后,需要将当前 sock 放到该结构体中:
|
|
这里 sk_prot->hash(sk) 调用了 net/ipv4/inet_hashtables.c:inet_hash() 方法:
|
|
Accept
accept 的作用就是从 accept 队列中取出三次握手完成的 sock,并将它关联到 vfs 上(其实操作和调用 sys_socket 时新建一个 socket 类似),然后返回。
这里还有个要注意的:
- 如果这个传递给 accept 的 socket 是非阻塞的话,就算 accept 队列为空,也会直接返回
- 如果这个传递给 accept 的 socket 是阻塞的话,会休眠掉,等待 accept 队列有数据后唤醒它
接下来我们就来看它的实现,accept 对应的系统调用是 sys_accept,而他则会调用 sys_accept4,因此我们直接来看 sys_accept4:
|
|
可以看到,这里创建了一个新的 socket 结构,然后调用了 sock->ops->accept,也就是 inet_accept。
实现
inet_accept
可以看到流程很简单,最终的实现都集中在 inet_accept 中了.而 inet_accept 主要工作如下:
- 调用
inet_csk_accept来进行对 accept 队列的操作,它会返回取得的 sock. - 将从
inet_csk_accept返回的 sock 链接到传递进来的 new 的 socket 中- 这里就知道我们上面为什么只需要 new 一个
socket而不是sock了,因为 sock 我们是直接从 accept 队列中取得的
- 这里就知道我们上面为什么只需要 new 一个
- 设置新的 socket 的状态为
SS_CONNECTED
|
|
inet_csk_accept
inet_csk_accept 就是从 accept 队列中取出 sock 然后返回
在看他的源码之前先来看几个相关函数的实现:
|
|
reqsk_queue_remove 主要是从 accept 队列中得到一个 sock
|
|
inet_csk_wait_for_connect 用来在 accept 队列为空的情况下,休眠掉一段时间 (这里每个 socket 都有一个等待队列的)
这里是每个调用的进程都会声明一个 wait 队列,然后将它连接到主的 socket 的等待队列链表中,然后休眠,。等到唤醒.
|
|
inet_csk_accept 中有个阻塞和非阻塞的问题.非阻塞的话会直接返回的,就算 accept 队列为空.这个时侯设置 errno 为-EAGAIN.
|
|
sock_graft
将 socket 和 sock 关联。
|
|
Connect
什么情况下,icsk_accept_queue 才不为空呢?当然是三次握手结束才可以。接下来我们来分析三次握手的过程。
三次握手一般是由客户端调用 connect 发起,它的具体流程是:
- 由 fd 得到 socket, 并且将地址复制到内核空间
- 调用
inet_stream_connect进行主要的处理
这里要注意 connect 也有个阻塞和非阻塞的区别,阻塞的话调用 inet_wait_for_connect 休眠,等待握手完成,否则直接返回。
|
|
client 端
inet_stream_connect
|
|
看到主要调用了 inet_stream_connect,它的主要工作是:
- 判断 socket 的状态,只有当为
SS_UNCONNECTED也就是非连接状态时才调用tcp_v4_connect来进行连接处理 - 判断 tcp 的状态 sk_state 只能为
TCPF_SYN_SENT或者 TCPF_SYN_RECV 才进入相关处理 - 如果状态合适并且 socket 为阻塞模式则调用
inet_wait_for_connect进入休眠等待三次握手完成来重新唤醒它。 - 否则直接返回,并设置错误号为 EINPROGRESS.
需要注意一下,如果为非阻塞模式,用户可以通过 select 来查看 socket 是否可读写,从而判断是否已连接。
|
|
tcp_v4_connect
tcp_v4_connect 的流程:
- 判断地址的一些合法性.
- 调用
ip_route_connect来查找出去的路由(包括查找临时端口等等)- 为什么呢?因为三次握手马上就要发送一个 SYN 包了,这就要凑齐源地址、源端口、目标地址、目标端口。目标地址和目标端口是服务端的,已经知道源端口是客户端随机分配的,源地址应该用哪一个呢?这时候要选择一条路由,看从哪个网卡出去,就应该填写哪个网卡的 IP 地址。
- 设置 sock 的状态为
TCP_SYN_SENT,并调用inet_hash_connect来查找一个临时端口(也就是我们出去的端口),并加入到对应的 hash 链表(具体操作和 get_port 很相似) - 调用
tcp_connect来完成最终的操作:这个函数主要用来初始化将要发送的 syn 包(包括窗口大小 isn 等等),然后将这个 sk_buffer 加入到 socket 的写队列。最终调用tcp_transmit_skb传输到 3 层。
|
|
tcp_connect
该函数完成发送 SYN 的作用,其流程如下:
- 初始化该次连接的 sk 结构 tcp_sock
- sk_stream_alloc_skb 分配一个 skb 数据包
tcp_init_nondata_skb初始化 skb,也就是 SYN 包- skb 加入发送队列后,调用 tcp_transmit_skb 发送该数据包(SYN)
- 更新 snd_nxt,inet_csk_reset_xmit_timer 启动超时定时器。如果 SYN 发送不成功,则再次发送
|
|
我们回到 __inet_stream_connect 函数,在调用 sk->sk_prot->connect 之后,inet_wait_for_connect 会一直等待客户端收到服务端的 ACK。而我们知道,服务端在 accept 之后,也是在等待中。
Server 端
为了解析三次握手,我们简单的看网络包接收到 TCP 层做的部分事情。
tcp_v4_rcv
|
|
我们通过 struct net_protocol 结构中的 handler 进行接收,调用的函数是 tcp_v4_rcv。接下来的调用链为 tcp_v4_rcv->tcp_v4_do_rcv->tcp_rcv_state_process。
tcp_rcv_state_process
tcp_rcv_state_process,顾名思义,是用来处理接收一个网络包后引起状态变化的。
|
|
目前服务端是处于 TCP_LISTEN 状态的,而且发过来的包是 SYN,因而就有了上面的代码,调用 icsk->icsk_af_ops->conn_request 函数。struct inet_connection_sock 对应的操作是 inet_connection_sock_af_ops,按照下面的定义,其实调用的是 tcp_v4_conn_request。
|
|
tcp_v4_conn_request
tcp_v4_conn_request 会调用 tcp_conn_request,这个函数也比较长,里面调用了 send_synack,但实际调用的是 tcp_v4_send_synack。具体发送的过程我们不去管它,看注释我们能知道,这是收到了 SYN 后,回复一个 SYN-ACK,回复完毕后,服务端处于 TCP_SYN_RECV。
|
|
这个时候,轮到客户端接收网络包了。都是 TCP 协议栈,所以过程和服务端没有太多区别,还是会走到 tcp_rcv_state_process 函数的,只不过由于客户端目前处于 TCP_SYN_SENT 状态,就进入了下面的代码分支。
|
|
tcp_rcv_synsent_state_process 会调用 tcp_send_ack,发送一个 ACK-ACK,发送后客户端处于 TCP_ESTABLISHED 状态。
又轮到服务端接收网络包了,我们还是归 tcp_rcv_state_process 函数处理。由于服务端目前处于状态 TCP_SYN_RECV 状态,因而又走了另外的分支。当收到这个网络包的时候,服务端也处于 TCP_ESTABLISHED 状态,三次握手结束。
|
|
Send
数据结构
msghdr
|
|
kiocb
|
|
iovec
|
|
实现
系统调用
|
|
__sys_sendto
|
|
sock_sendmsg
|
|
最终调用了 sock->ops->sendmsg,也就是 inet_sendmsg
inet_sendmsg
可以看到,最终调用的是 sk_prot 的 sendmsg 方法,对于 TCP 就是 tcp_sendmsg
|
|
tcp_sendmsg
|
|
tcp_sendmsg 的实现还是很复杂的,这里面做了这样几件事情。
- msg 是用户要写入的数据,这个数据要拷贝到内核协议栈里面去发送;在内核协议栈里面,网络包的数据都是由 struct sk_buff 维护的,因而第一件事情就是找到一个空闲的内存空间,将用户要写入的数据,拷贝到 struct sk_buff 的管辖范围内。而第二件事情就是发送 struct sk_buff。
- 在 tcp_sendmsg 中,我们首先通过强制类型转换,将 sock 结构转换为 struct tcp_sock,这个是维护 TCP 连接状态的重要数据结构。
- 接下来是 tcp_sendmsg 的第一件事情,把数据拷贝到 struct sk_buff。
我们先声明一个变量 copied,初始化为 0,这表示拷贝了多少数据。紧接着是一个循环,while (msg_data_left(msg)),也即如果用户的数据没有发送完毕,就一直循环。循环里声明了一个 copy 变量,表示这次拷贝的数值,在循环的最后有 copied += copy,将每次拷贝的数量都加起来。
我们这里只需要看一次循环做了哪些事情。
-
tcp_write_queue_tail 从 TCP 写入队列 sk_write_queue 中拿出最后一个 struct sk_buff,在这个写入队列中排满了要发送的 struct sk_buff,为什么要拿最后一个呢?这里面只有最后一个,可能会因为上次用户给的数据太少,而没有填满。
-
tcp_send_mss 会计算 MSS,也即 Max Segment Size。这是什么呢?这个意思是说,我们在网络上传输的网络包的大小是有限制的,而这个限制在最底层开始就有。
-
如果 copy 小于 0,说明最后一个 struct sk_buff 已经没地方存放了,需要调用 sk_stream_alloc_skb,重新分配 struct sk_buff,然后调用 skb_entail,将新分配的 sk_buff 放到队列尾部。
-
为了减少内存拷贝的代价,有的网络设备支持分散聚合(Scatter/Gather)I/O,顾名思义,就是 IP 层没必要通过内存拷贝进行聚合,让散的数据零散的放在原处,在设备层进行聚合。在注释 /_ Where to copy to? _/ 后面有个 if-else 分支。
- if 分支就是 skb_add_data_nocache 将数据拷贝到连续的数据区域。
- else 分支就是 skb_copy_to_page_nocache 将数据拷贝到 struct skb_shared_info 结构指向的不需要连续的页面区域。
-
就是要发生网络包了。
- 第一种情况是积累的数据报数目太多了,因而我们需要通过调用
__tcp_push_pending_frames发送网络包。 - 第二种情况是,这是第一个网络包,需要马上发送,调用 tcp_push_one
- 无论
__tcp_push_pending_frames还是tcp_push_one,都会调用 tcp_write_xmit 发送网络包。
- 第一种情况是积累的数据报数目太多了,因而我们需要通过调用
tcp_write_xmit
这里面主要的逻辑是一个循环,用来处理发送队列,只要队列不空,就会发送。
主要涉及 TSO 和滑动窗口拥塞控制相关的部分,此处不介绍了,最终调用了 tcp_transmit_skb。
|
|
tcp_transmit_skb
|
|
tcp_transmit_skb 这个函数比较长,主要做了两件事情:
- 填充 TCP 头
- 会调用 icsk_af_ops 的 queue_xmit 方法
- icsk_af_ops 指向 ipv4_specific,也即调用的是 ip_queue_xmit 函数
|
|
Recv
数据到达
tcp_v4_rcv
|
|
在 tcp_v4_rcv 中,得到 TCP 的头之后,我们可以开始处理 TCP 层的事情。因为 TCP 层是分状态的,状态被维护在数据结构 struct sock 里面,因而我们要根据 IP 地址以及 TCP 头里面的内容,在 tcp_hashinfo 中找到这个包对应的 struct sock,从而得到这个包对应的连接的状态。
接下来,我们就根据不同的状态做不同的处理,例如,上面代码中的 TCP_LISTEN、TCP_NEW_SYN_RECV 状态属于连接建立过程中。这个我们在讲三次握手的时候讲过了。再如,TCP_TIME_WAIT 状态是连接结束的时候的状态,这个我们暂时可以不用看。
接下来,我们来分析最主流的网络包的接收过程,这里面涉及三个队列:
- backlog 队列
- prequeue 队列
- sk_receive_queue 队列
为什么接收网络包的过程,需要在这三个队列里面倒腾过来、倒腾过去呢?这是因为,同样一个网络包要在三个主体之间交接。
- 第一个主体是软中断的处理过程。如果你没忘记的话,我们在执行 tcp_v4_rcv 函数的时候,依然处于软中断的处理逻辑里,所以必然会占用这个软中断。
- 第二个主体就是用户态进程。如果用户态触发系统调用 read 读取网络包,也要从队列里面找。
- 第三个主体就是内核协议栈。哪怕用户进程没有调用 read,读取网络包,当网络包来的时候,也得有一个地方收着呀。
这时候,我们就能够了解上面代码中 sock_owned_by_user 的意思了,其实就是说,当前这个 sock 是不是正有一个用户态进程等着读数据呢,如果没有,内核协议栈也调用 tcp_add_backlog,暂存在 backlog 队列中,并且抓紧离开软中断的处理过程。
如果有一个用户态进程等待读取数据呢?我们先调用 tcp_prequeue,也即赶紧放入 prequeue 队列,并且离开软中断的处理过程。在这个函数里面,我们会看到对于 sysctl_tcp_low_latency 的判断,也即是不是要低时延地处理网络包。
如果把 sysctl_tcp_low_latency 设置为 0,那就要放在 prequeue 队列中暂存,这样不用等待网络包处理完毕,就可以离开软中断的处理过程,但是会造成比较长的时延。如果把 sysctl_tcp_low_latency 设置为 1,我们还是调用 tcp_v4_do_rcv。
tcp_v4_do_rcv
|
|
在 tcp_v4_do_rcv 中,分两种情况,一种情况是连接已经建立,处于 TCP_ESTABLISHED 状态,调用 tcp_rcv_established。另一种情况,就是其他的状态,调用 tcp_rcv_state_process。
tcp_v4_established
在连接状态下,我们会调用 tcp_rcv_established。在这个函数里面,我们会调用 tcp_data_queue,将其放入 sk_receive_queue 队列进行处理。
tcp_data_queue
|
|
在 tcp_data_queue 中,对于收到的网络包,我们要分情况进行处理。
第一种情况,seq == tp->rcv_nxt,说明来的网络包正是我服务端期望的下一个网络包。这个时候我们判断 sock_owned_by_user,也即用户进程也是正在等待读取,这种情况下,就直接 skb_copy_datagram_msg,将网络包拷贝给用户进程就可以了。
如果用户进程没有正在等待读取,或者因为内存原因没有能够拷贝成功,tcp_queue_rcv 里面还是将网络包放入 sk_receive_queue 队列。
接下来,tcp_rcv_nxt_update 将 tp->rcv_nxt 设置为 end_seq,也即当前的网络包接收成功后,更新下一个期待的网络包。
这个时候,我们还会判断一下另一个队列,out_of_order_queue,也看看乱序队列的情况,看看乱序队列里面的包,会不会因为这个新的网络包的到来,也能放入到 sk_receive_queue 队列中。
实现
系统调用
|
|
__sys_recvfrom
|
|
sock_recvmsg
|
|
最终调用了 sock->ops->recvmsg,也就是 inet_recvmsg
inet_recvmsg
可以看到,最终调用的是 sk_prot 的 recvmsg 方法,对于 TCP 就是 tcp_recvmsg
|
|
tcp_recvmsg
|
|
tcp_recvmsg 这个函数比较长,里面逻辑也很复杂,好在里面有一段注释概括了这里面的逻辑。注释里面提到了三个队列,receive_queue 队列、prequeue 队列和 backlog 队列。这里面,我们需要把前一个队列处理完毕,才处理后一个队列。
tcp_recvmsg 的整个逻辑也是这样执行的:这里面有一个 while 循环,不断地读取网络包。
这里,我们会先处理 sk_receive_queue 队列。如果找到了网络包,就跳到 found_ok_skb 这里。这里会调用 skb_copy_datagram_msg,将网络包拷贝到用户进程中,然后直接进入下一层循环。
直到 sk_receive_queue 队列处理完毕,我们才到了 sysctl_tcp_low_latency 判断。如果不需要低时延,则会有 prequeue 队列。于是,我们能就跳到 do_prequeue 这里,调用 tcp_prequeue_process 进行处理。
如果 sysctl_tcp_low_latency 设置为 1,也即没有 prequeue 队列,或者 prequeue 队列为空,则需要处理 backlog 队列,在 release_sock 函数中处理。
release_sock 会调用 __release_sock,这里面会依次处理队列中的网络包。
|
|
最后,哪里都没有网络包,我们只好调用 sk_wait_data,继续等待在哪里,等待网络包的到来。
至此,网络包的接收过程到此结束。
参考资料
- TCP/IP Architecture, Design and Implementation in Linux:Chapter 3
- Linux 4.4 之后 TCP 三路握手的新流程
-
No backlinks found.