想象一下,互联网就像一片无边无际的海洋,而 Socket 就是那艘能够带你航行至任何角落的神奇海盗船。初次接触 Socket 的你,或许正像一位刚刚登陆神秘岛屿的探险者,面对浩瀚的数据海洋,心中充满了好奇与困惑。
但在本文中,我们将一起揭开 Socket 的神秘面纱,从一个网络新手的角度出发,逐步探索 Socket 的工作原理及其在操作系统内核中的实现方式。你将学会如何驾驭这艘数据之舟,如何在互联网的波涛中自由穿梭,最终成为一名能够征服数据风暴的海盗王。
准备好了吗?让我们扬帆起航,开始这段充满欢笑与挑战的网络冒险之旅吧!
Socket基础
生活中的插座:
在日常生活中,插座(或插头插座)是一种物理接口,允许电器设备通过电线与家庭电路连接,从而获取电力。插座定义了特定的形状、大小和电气特性,确保只有正确的插头才能插入,防止电气事故并确保设备能够正常运行。插座和插头之间形成的是一个物理连接,用于传输电流。
计算机网络中的 Socket:
在计算机网络中,Socket 提供了一种抽象的通信接口,允许应用程序在网络上与其他应用程序进行数据交换。Socket 定义了网络通信的规则,包括使用哪种网络协议(如 TCP 或 UDP)、目标的 IP 地址和端口号等。通过 Socket,应用层的软件可以与传输层的协议交互,而不必关心底层网络的细节。Socket 和另一端的 Socket 之间形成的是逻辑连接,用于传输数据包。
其实两者非常相似。通过socket
,我们可以与某台机子建立"连接",建立"连接"的过程,就像是将插口插入插槽一样。
我们想要将数据从 A 电脑的某个进程发到 B 电脑的某个进程。
这时候我们需要选择将数据发过去的方式,如果需要确保数据要能发给对方,那就选可靠的TCP
协议,如果数据丢了也没关系,看天意,那就选择不可靠的UDP
协议。
初学者毫无疑问,首选TCP
。
那这时候就需要用socket
进行编程。
于是第一步就是创建个关于 TCP 的socket
。就像下面这样。
sock_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
这个方法会返回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
里加入IP 和端口字段。
而第二个问题,我们会发现这些协议虽然各不相同,但还是有一些功能相似的地方,比如收发数据时的一些逻辑完全可以复用。按面向对象编程的思想,我们可以将不同的协议当成是不同的对象类(或结构体),将公共的部分提取出来,通过"继承"的方式,复用功能。
于是,我们将功能重新划分下,定义了一些数据结构。
-
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
。
好了,现在有了这套数据结构,每个数据结构实现自己职责范围内的功能,然后再将它们跟硬件网卡对接一下,就实现了网络传输的功能。
可以想象到,这里面的代码肯定非常复杂,同时还操作了网卡硬件,需要比较高的操作系统权限,再考虑到性能和安全,于是决定将它放在操作系统内核里。
既然跟远端服务端进程收发数据可以抽象为“读和写”,操作文件也可以抽象为"读和写",正好有句话叫,“linux 里一切皆是文件”,那我们索性,将内核的 sock
封装成文件就好了。创建sock
的同时也创建一个文件,文件有个句柄 fd,说白了就是个文件系统里的身份证号码,通过它可以唯一确定是哪个sock
。
将句柄暴露给用户,之后用户操作这个文件句柄,文件系统就会将操作指向内核sock
结构。
有了sock fd句柄
之后,我们将这部分功能抽象成一个个简单的 API 接口,让用户更方便的实现特定的网络编程功能。以后别人只需要调用这些 API 接口,就可以驱动我们写好的这一大堆复杂的数据结构去发送数据。这些 API 接口,我们列了一下,发现需要有send()
,recv()
,bind()
, listen()
,connect()
这些。
现在是不是眼熟了,上面这些接口方法其实就是 socket 提供出来的接口。
到这里,我们的内核网络传输功能就算设计完成了。
所以说,socket 其实就是个代码库或接口层,它介于内核和应用程序之间,提供了一些高度封装过的接口,让我们去使用内核网络的传输功能。
到这里,我们应该明白了。我们平时写的应用程序里代码里虽然用了 socket 实现了收发数据包的功能,但其实真正执行网络通信功能的,不是应用程序,而是linux 内核。相当于应用程序通过 socket 提供的接口,将网络传输的这部分工作外包给了linux 内核。
这听起来像不像我们最熟悉的前后端分离的服务架构,虽然这么说不太严谨,但看上去 linux 就像是被分成了应用程序和内核两个服务。内核就像是后端,暴露了好多个api 接口,其中一类就是 socket 的send()
和recv()
这些方法。应用程序就像是前端,负责调用内核提供的接口来实现想要的功能。
看到这里,我担心大家会有点混乱,来做个有意思易懂的总结。
想象一下,操作系统内核就像一座巨大的城堡,里面隐藏着无数神秘的房间和通道,其中有一条非常重要的路径被称为 sock
。这条路径负责处理所有进出城堡的信息,无论是 TCP 的骑士
还是 UDP 的信使
,都必须通过这条路径才能完成他们的使命。
为了方便城堡外的世界——即用户空间的应用程序——能够与城堡内部沟通,城堡的建筑师们设计了一个精妙的系统:socket 层
。它就像是城堡墙上的一个小窗口,通过这个窗口,外面的人可以与城堡内的 sock 通道
建立联系。但是,要打开这个窗口,你需要一把钥匙,这把钥匙就是 socket_fd
—— 一个 int 类型的数字,它代表了一个特殊文件的句柄,这个文件就是 sock 的化身。
当你拥有 socket_fd
后,就等于拥有了与 sock
对话的能力。你可以通过这个数字向城堡内部发送请求,也可以接收来自城堡内部的消息。这就好比你获得了一个魔法数字,通过它,你可以开启一条秘密通道,与城堡内的神秘世界进行交流。
现在回去看socket的中文翻译,套接字,我将它理解为一套用于连接的数字,是不是就觉得特别合理了。
所以,下次当你在代码中看到 socket_fd
时,记得它不仅仅是一个 int 类型的变量,它背后承载着与整个网络世界对话的无限可能。
Socket怎么实现网络通信功能
这套 sock 的结构其实非常复杂。我们以最常用的 TCP 协议为例,简单了解下它是怎么实现网络传输功能的。
我将它分为两阶段,分别是建立连接和数据传输。
建立连接
对于 TCP,要传数据,就得先在客户端和服务端中间建立连接。
在客户端,代码执行 socket 提供的connect(sockfd, "ip:port")
方法时,会通过sockfd 句柄找到对应的文件,再根据文件里的信息指向内核的sock结构。通过这个 sock 结构主动发起三次握手。
在服务端握手次数还没达到"三次"的连接,叫半连接,完成好三次握手的连接,叫全连接。它们分别会用半连接队列和全连接队列来存放,这两个队列会在你执行listen()
方法的时候创建好。当服务端执行accept()
方法时,就会从全连接队列里拿出一条全连接。
至此,连接就算准备好了,之后,就可以开始传输数据。
数据传输
为了实现发送和接收数据的功能,sock 结构体里带了一个发送缓冲区和一个接收缓冲区,说是缓冲区,但其实就是个链表,上面挂着一个个准备要发送或接收的数据。
当应用执行send()
方法发送数据时,同样也会通过sock_fd句柄
找到对应的文件,根据文件指向的sock
结构,找到这个sock结构里带的发送缓冲区,将数据会放到发送缓冲区,然后结束流程,内核看心情决定什么时候将这份数据发送出去。
接收数据流程也类似,当数据送到 linux 内核后,数据不是立马给到应用程序的,而是先放在接收缓冲区中,数据静静躺着,卑微的等待应用程序什么时候执行recv()
方法来拿一下。
那么问题来了,发送数据是应用程序主动发起,这个大家都没问题。
那接收数据呢?数据从远端发过来了,怎么通知并给到应用程序呢?
这就需要用到等待队列。
当你的应用进程执行recv()
方法尝试获取(阻塞场景下)接收缓冲区的数据时。
- 如果有数据,那正好,取走就好了。这点没啥疑问。
- 但如果没数据,就会将自己的进程信息注册到这个 sock 用的等待队列里,然后进程休眠。如果这时候有数据从远端发过来了,数据进入到接收缓冲区时,内核就会取出 sock 的等待队列里的进程,唤醒进程来取数据。
有时候,你会看到多个进程通过
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 key
再到这个hash表
里重新取出对应的 sock就好了。所以说服务端是通过四元组来区分多个客户端的。
sock怎么实现“继承”
大家都知道 linux 内核是 C 语言实现的,而C 语言没有类也没有继承的特性,是怎么做到"继承"的效果的呢?
在 C 语言里,结构体里的内存是连续的,将要继承的"父类",放到结构体的第一位,就像下面这样。
struct tcp_sock {
/* inet_connection_sock has to be the first member of tcp_sock */
struct inet_connection_sock inet_conn;
// 其他字段
}
struct inet_connection_sock {
/* inet_sock has to be the first member! */
struct inet_sock icsk_inet;
// 其他字段
}
然后我们就可以通过结构体名的长度来强行截取内存,这样就能转换结构体,从而实现类似"继承"的效果。
// sock 转为 tcp_sock
static inline struct tcp_sock *tcp_sk(const struct sock *sk)
{
return (struct tcp_sock *)sk;
}
小结
在文章的末尾,我们发现原来计算机网络中的Socket
并不是什么高深莫测的东西,它就像生活中那个小小的插座,只不过它的作用更大一些,因为它能让我们的电脑和互联网连在一起,让信息得以自由地流动。而且,我们还了解到,Socket
其实也有自己的家族,每个成员都有自己的特点和用途,真是让人眼界大开啊!
最后,我想说一句:“别小瞧了那个小小的Socket,它可是网络世界的大功臣!”