从初学者角度聊一聊 socket 到底是什么?
我相信大家刚开始学 socket 的时候,都跟我一样。
云里雾里的,对 socket 的概念很模糊。
这篇文章我打算从一个初学者的角度开始聊起,让大家了解下我眼里的 socket 是什么以及 socket 的原理和内核实现。
socket 的概念
故事要从一个插头说起。
插头与插座
当我将插头插入插座,那看起来就像是将两者连起来了。
风扇与电力系统建立"连接"
而插座的英文,又叫 socket。
巧了,我们程序员搞网络编程时也会用到一个叫 socket 的东西。
其实两者非常相似。通过 socket,我们可以与某台机子建立"连接",建立"连接"的过程,就像是将插口插入插槽一样。
大概概念是了解了,但我相信各位对 socket 其实还是很模糊。
我们从大家最熟悉的使用场景开始说起。
socket 的使用场景
我们想要将数据从 A 电脑的某个进程发到 B 电脑的某个进程。
这时候我们需要选择将数据发过去的方式,如果需要确保数据要能发给对方,那就选可靠的 TCP 协议,如果数据丢了也没关系,看天意,那就选择不可靠的 UDP 协议。
初学者毫无疑问,首选 TCP。
TCP 是什么
那这时候就需要用 socket 进行编程。
于是第一步就是创建个关于 TCP 的 socket。就像下面这样。
这个方法会返回 socket_fd,它是 socket 文件的句柄,是个数字,相当于 socket 的身份证号。
得到了 socket_fd 之后,对于服务端,就可以依次执行 bind(), listen(), accept()方法,然后坐等客户端的连接请求。
对于客户端,得到 socket_fd 之后,你就可以执行 connect()方法向服务端发起建立连接的请求,此时就会发生 TCP 三次握手。
握手建立连接流程
连接建立完成后,客户端可以执行 send() 方法发送消息,服务端可以执行 recv()方法接收消息,反过来,服务器也可以执行 send(),客户端执行 recv()方法。
到这里为止,就是我们大部分程序员最熟悉的使用场景。
socket 的设计
现在,socket 我们见过,也用过,但对大部分程序员来说,它是个黑盒。
那既然是黑盒,我们索性假设我们忘了 socket。重新设计一个内核网络传输功能。
网络传输,从操作上来看,无非就是,发数据和远端之间互相收发数据。也就是对应着写数据和读数据。
读写收发
但显然,事情没那么简单。
这里还有两个问题。
第一个是,接收端和发送端可能不止一个,因此我们需要一些信息做下区分,这个大家肯定很熟悉,可以用 IP 和端口。IP 用来定位是哪台电脑,端口用来定位是这台电脑上的哪个进程。
第二个是,发送端和接收端的传输方式有很多区别,可以是可靠的 TCP 协议,也可以是不可靠的 UDP 协议,甚至还需要支持基于 icmp 协议的 ping 命令。
sock 是什么
写过代码的都知道,为了支持这些功能,我们需要定义一个数据结构去支持这些功能。
这个数据结构,叫 sock。
为了解决上面的第一个问题,我们可以在 sock 里加入 IP 和端口字段。
sock 加入 IP 和端口字段
而第二个问题,我们会发现这些协议虽然各不相同,但还是有一些功能相似的地方,比如收发数据时的一些逻辑完全可以复用。按面向对象编程的思想,我们可以将不同的协议当成是不同的对象类(或结构体),将公共的部分提取出来,通过"继承"的方式,复用功能。
基于各种 sock 实现网络传输功能
于是,我们将功能重新划分下,定义了一些数据结构。
继承 sock 的各类 sock
sock 是最基础的结构,维护一些任何协议都有可能会用到的收发数据缓冲区。
inet_sock 特指用了网络传输功能的 sock,在 sock 的基础上还加入了 TTL,端口,IP 地址这些跟网络传输相关的字段信息。说到这里大家就懵了,难道还有不是用网络传输的?有,比如 Unix domain socket,用于本机进程之间的通信,直接读写文件,不需要经过网络协议栈。这是个非常有用的东西,我以后一定讲讲(画饼)。
inet_connection_sock 是指面向连接的 sock,在 inet_sock 的基础上加入面向连接的协议里相关字段,比如 accept 队列,数据包分片大小,握手失败重试次数等。虽然我们现在提到面向连接的协议就是指 TCP,但设计上 linux 需要支持扩展其他面向连接的新协议,
tcp_sock 就是正儿八经的 tcp 协议专用的 sock 结构了,在 inet_connection_sock 基础上还加入了 tcp 特有的滑动窗口、拥塞避免等功能。同样 udp 协议也会有一个专用的数据结构,叫 udp_sock。
好了,现在有了这套数据结构,我们将它们跟硬件网卡对接一下,就实现了网络传输的功能。
提供 socket 层
可以想象得到,这里面的代码肯定非常复杂,同时还操作了网卡硬件,需要比较高的操作系统权限,再考虑到性能和安全,于是决定将它放在操作系统内核里。
既然网络传输功能做在内核里,那用户空间的应用程序想要用这部分功能的话,该怎么办呢?
这个好办,本着不重复造轮子的原则,我们将这部分功能抽象成一个个简单的接口。以后别人只需要调用这些接口,就可以驱动我们写好的这一大堆复杂的数据结构去发送数据。
那么问题来了,怎么样将这部分功能暴露出去呢?让其他程序员更方便的使用呢?
既然跟远端服务端进程收发数据可以抽象为“读和写”,操作文件也可以抽象为"读和写",正好有句话叫,"linux 里一切皆是文件",那我们索性,将内核的 sock 封装成文件就好了。创建 sock 的同时也创建一个文件,文件有个句柄 fd,说白了就是个文件系统里的身份证号码,通过它可以唯一确定是哪个 sock。
这个文件句柄 fd 其实就是 sock_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) 里的 sock_fd。
将句柄暴露给用户,之后用户就可以像操作文件句柄那样去操作这个 sock 句柄。在用户空间里操作这个句柄,文件系统就会将操作指向内核 sock 结构。
是的,操作这个特殊的文件就相当于操作内核里对应的 sock。
通过文件找到 sock
有了 sock_fd 句柄之后,我们就需要提供一些接口方法,让用户更方便的实现特定的网络编程功能。这些接口,我们列了一下,发现需要有 send(),recv(),bind(), listen(),connect()这些。到这里,我们的内核网络传输功能就算设计完成了。
现在是不是眼熟了,上面这些接口方法其实就是 socket 提供出来的接口。
所以说,socket 其实就是个代码库 or 接口层,它介于内核和应用程序之间,提供了一些高度封装过的接口,让我们去使用内核网络传输功能。
基于 sock 实现网络传输功能
到这里,我们应该明白了。我们平时写的应用程序里代码里虽然用了 socket 实现了收发数据包的功能,但其实真正执行网络通信功能的,不是应用程序,而是 linux 内核。相当于应用程序通过 socket 提供的接口,将网络传输的这部分工作外包给了 linux 内核。
这听起来像不像我们最熟悉的前后端分离的服务架构,虽然这么说不太严谨,但看上去 linux 就像是被分成了应用程序和内核两个服务。内核就像是后端,暴露了好多个 api 接口,其中一类就是 socket 的 send()和 recv()这些方法。应用程序就像是前端,负责调用内核提供的接口来实现想要的功能。
进程通过 socket 调用内核功能
看到这里,我担心大家会有点混乱,来做个小的总结。
在操作系统内核空间里,实现网络传输功能的结构是 sock,基于不同的协议和应用场景,会被泛化为各种类型的 xx_sock,它们结合硬件,共同实现了网络传输功能。为了将这部分功能暴露给用户空间的应用程序使用,于是引入了 socket 层,同时将 sock 嵌入到文件系统的框架里,sock 就变成了一个特殊的文件,用户就可以在用户空间使用文件句柄,也就是 socket_fd 来操作内核 sock 的网络传输能力。
这个 socket_fd 是一个 int 类型的数字。现在回去看 socket 的中文翻译,套接字,我将它理解为一套用于连接的数字,是不是就觉得特别合理了。
网络分层与基于 sock 实现网络传输功能
socket 如何实现网络通信
上面关于怎么实现网络通信功能这一块一笔带过了。
现在我们来聊聊。
这套 sock 的结构其实非常复杂。我们以最常用的 TCP 协议为例,简单了解下它是怎么实现网络传输功能的。
我将它分为两阶段,分别是建立连接和数据传输。
建立连接
对于 TCP,要传数据,就得先在客户端和服务端中间建立连接。
在客户端,代码执行 socket 提供的 connect(sockfd, "ip:port")方法时,会通过 sockfd 句柄找到对应的文件,再根据文件里的信息指向内核的 sock 结构。通过这个 sock 结构主动发起三次握手。
TCP 三次握手
在服务端握手次数还没达到"三次"的连接,叫半连接,完成好三次握手的连接,叫全连接。它们分别会用半连接队列和全连接队列来存放,这两个队列会在你执行 listen()方法的时候创建好。当服务端执行 accept()方法时,就会从全连接队列里拿出一条全连接。
半连接队列和全连接队列
至此,连接就算准备好了,之后,就可以开始传输数据。
虽然都叫队列,但半连接队列其实是个 hash 表,而全连接队列其实是个链表。
那么问题来了,为什么半连接队列要设计成哈希表而全连接队列是个链表?这个在我在我之前写的《没有 accept,能建立 TCP 连接吗?》 已经提到过,不再重复。
数据传输
为了实现发送和接收数据的功能,sock 结构体里带了一个发送缓冲区和一个接收缓冲区,说是缓冲区,但其实就是个链表,上面挂着一个个准备要发送或接收的数据。
当应用执行 send()方法发送数据时,同样也会通过 sock_fd 句柄找到对应的文件,根据文件指向的 sock 结构,找到这个 sock 结构里带的发送缓冲区,将数据会放到发送缓冲区,然后结束流程,内核看心情决定什么时候将这份数据发送出去。
接收数据流程也类似,当数据送到 linux 内核后,数据不是立马给到应用程序的,而是先放在接收缓冲区中,数据静静躺着,卑微的等待应用程序什么时候执行 recv()方法来拿一下。就像我的文章,躺在你的推文列表里,卑微的等一个点赞关注转发三连。懂?
sock 的发送和接收缓冲区
IP 和端口其实不在 sock 下,而在 inet_sock 下,上面这么画只是为了简化。。。
那么问题来了,发送数据是应用程序主动发起,这个大家都没问题。
那接收数据呢?数据从远端发过来了,怎么通知并给到应用程序呢?
这就需要用到等待队列。
sock 内的等待队列
当你的应用进程执行 recv()方法尝试获取(阻塞场景下)接收缓冲区的数据时。
• 如果有数据,那正好,取走就好了。这点没啥疑问。
• 但如果没数据,就会将自己的进程信息注册到这个 sock 用的等待队列里,然后进程休眠。如果这时候有数据从远端发过来了,数据进入到接收缓冲区时,内核就会取出 sock 的等待队列里的进程,唤醒进程来取数据。
recv 时无数据进程进入等待队列
有时候,你会看到多个进程通过 fork 的方式,listen 了同一个 socket_fd。在内核,它们都是同一个 sock,多个进程执行 listen()之后,都嗷嗷等待连接进来,所以都会将自身的进程信息注册到这个 socket_fd 对应的内核 sock 的等待队列中。如果这时真来了一个连接,是该唤醒等待队列里的哪个进程来接收连接呢?这个问题的答案比较有趣。
• 在 linux 2.6 以前,会唤醒等待队列里的所有进程。但最后其实只有一个进程会处理这个连接请求,其他进程又重新进入休眠,这些被唤醒了又无事可做最后只能重新回去休眠的进程会消耗一定的资源。就好像你在广东的街头,想问路,叫一声靓仔,几十个人同时回头,但你其实只需要其中一个靓仔告诉你路该怎么走。你这种一不小心惊动这群靓仔的场景,在计算机领域中,就叫惊群效应。
• 在 linux 2.6 之后,只会唤醒等待队列里的其中一个进程。是的,socket 监听的惊群效应问题被修复了。
惊群效应
看到这里,问题又来了。
服务端 listen 的时候,那么多数据到一个 socket 怎么区分多个客户端的?
以 TCP 为例,服务端执行 listen 方法后,会等待客户端发送数据来。客户端发来的数据包上会有源 IP 地址和端口,以及目的 IP 地址和端口,这四个元素构成一个四元组,可以用于唯一标记一个客户端。
其实说四元组并不严谨,因为过程中还有很多其他信息,也可以说是五元组。。。但大概理解就好,就这样吧。。。
四元组
服务端会创建一个新的内核 sock,并用四元组生成一个 hash key,将它放入到一个 hash 表中。
四元组映射成 hash 键
下次再有消息进来的时候,通过消息自带的四元组生成 hash key 再到这个 hash 表里重新取出对应的 sock 就好了。所以说服务端是通过四元组来区分多个客户端的。
多个 hash_key 对应多个客户端
sock 怎么实现"继承"
最后遗留一个问题。
大家都知道 linux 内核是 C 语言实现的,而 C 语言没有类也没有继承的特性,是怎么做到"继承"的效果的呢?
在 C 语言里,结构体里的内存是连续的,将要继承的"父类",放到结构体的第一位,就像下面这样。
然后我们就可以通过结构体名的长度来强行截取内存,这样就能转换结构体,从而实现类似"继承"的效果。
内存布局
总结
• socket 中文套接字,我理解为一套用于连接的数字。并不一定准确,欢迎评论。
• sock 在内核,socket_fd 在用户空间,socket 层介于内核和用户空间之间。
• 在操作系统内核空间里,实现网络传输功能的结构是 sock,基于不同的协议和应用场景,会被泛化为各种类型的 xx_sock,它们结合硬件,共同实现了网络传输功能。为了将这部分功能暴露给用户空间的应用程序使用,于是引入了 socket 层,同时将 sock 嵌入到文件系统的框架里,sock 就变成了一个特殊的文件,用户就可以在用户空间使用文件句柄,也就是 socket_fd 来操作内核 sock 的网络传输能力。
• 服务端可以通过四元组来区分多个客户端。
• 内核通过 c 语言"结构体里的内存是连续的"这一特点实现了类似继承的效果。
评论