Socket套接字

17 阅读8分钟

Sock

  • 网络传输,从操作上来看,无非就是,发数据和远端之间互相收发数据。也就是对应着写数据和读数据。

  • 这里还有两个问题。

    • 第一个是,接收端和发送端可能不止一个,因此我们需要一些信息做下区分,这个大家肯定很熟悉,可以用IP和端口。IP用来定位是哪台电脑,端口用来定位是这台电脑上的哪个进程。
    • 第二个是,发送端和接收端的传输方式有很多区别,可以是可靠的TCP协议,也可以是不可靠的UDP协议,甚至还需要支持基于icmp协议的ping命令。

  • 为了支持这些功能,我们需要定义一个数据结构去支持这些功能。
  • 这个数据结构,叫sock。
  • 首先,可以在sock里加入IP和端口字段。这样解决了第一个问题
  • 第二个问题,我们会发现这些协议虽然各不相同,但还是有一些功能相似的地方,比如收发数据时的一些逻辑完全可以复用。按面向对象编程的思想,我们可以将不同的协议当成是不同的对象类(或结构体),将公共的部分提取出来,通过"继承"的方式,复用功能。

基于sock实现网络传输功能

  • 于是,我们将功能重新划分下,定义了一些数据结构。

  • sock是最基础的结构,维护一些任何协议都有可能会用到的收发数据缓冲区。
  • inet_sock特指用了网络传输功能的sock,在sock的基础上还加入了TTL,端口,IP地址这些跟网络传输相关的字段信息。
  • 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_fd句柄之后,我们就需要提供一些接口方法,让用户更方便的实现特定的网络编程功能。这些接口,我们列了一下,发现需要有send(),recv(),bind(), listen(),connect()这些。到这里,我们的内核网络传输功能就算设计完成了。
  • socket其实就是个代码库 or 接口层,它介于内核和应用程序之间,提供了一些高度封装过的接口,让我们去使用内核网络传输功能。
  • 平时写的应用程序里代码里虽然用了socket实现了收发数据包的功能,但其实真正执行网络通信功能的,不是应用程序,而是linux内核。相当于应用程序通过socket提供的接口,将网络传输的这部分工作外包给了linux内核。
  • 这听起来像不像我们最熟悉的前后端分离的服务架构,虽然这么说不太严谨,但看上去linux就像是被分成了应用程序和内核两个服务。内核就像是后端,暴露了好多个api接口,其中一类就是socket的send()和recv()这些方法。应用程序就像是前端,负责调用内核提供的接口来实现想要的功能。

  • 在操作系统内核空间里,实现网络传输功能的结构是sock,基于不同的协议和应用场景,会被泛化为各种类型的xx_sock,它们结合硬件,共同实现了网络传输功能。为了将这部分功能暴露给用户空间的应用程序使用,于是引入了socket层,同时将sock嵌入到文件系统的框架里,sock就变成了一个特殊的文件,用户就可以在用户空间使用文件句柄,也就是socket_fd来操作内核sock的网络传输能力。
  • 这个socket_fd是一个int类型的数字。现在回去看socket的中文翻译,套接字,将它理解为一套用于连接的数字。

socket实现网络通信

  • 整个阶段分为两部分,分别是建立连接和数据传输。
  • 建立连接

  • 在客户端,代码执行socket提供的connect(sockfd, "ip:port")方法时,会通过sockfd句柄找到对应的文件,再根据文件里的信息指向内核的sock结构。通过这个sock结构主动发起三次握手。
  • 在服务端握手次数还没达到"三次"的连接,叫半连接,完成好三次握手的连接,叫全连接。它们分别会用半连接队列和全连接队列来存放,这两个队列会在你执行listen()方法的时候创建好。当服务端执行accept()方法时,就会从全连接队列里拿出一条全连接。
  • 至此,连接就算准备好了,之后,就可以开始传输数据。

虽然都叫队列,但半连接队列其实是个hash表,而全连接队列其实是个链表。

那么问题来了,为什么半连接队列要设计成哈希表而全连接队列是个链表?这个在我在我之前写的《没有accept,能建立TCP连接吗?》 已经提到过,不再重复。

  • 数据传输

    • 为了实现发送和接收数据的功能,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监听的惊群效应问题被修复了。