1 socket 简介
socket 是处于传输层之上封装的网络接口,方便应用层、会话层等使用。socket 的本意是“插口或插槽”,顾名思义,可以将 socket 想象成一根网线,一头插在客户端,一头插在服务端,然后进行通信,故客户端与服务端在通信之前必须要创建一个 socket。
由于此 socket 对于 tcp/ip 各种协议通用,故 socket 必须提供各种选项,例如使用 IPv4 和 IPv6 分别对应选项 AF_INET 和 AF_INET6。另外,还要指定到底是 TCP 还是 UDP,由于 TCP 是基于数据流的,所以设置为 SOCK_STREAM,而 UDP 是基于数据报的,因而设置为 SOCK_DGRAM。
从上述 socket 描述中可以看出,socket 是一个端到端的通信,关于中间经过多少局域网,多少路由器,因而能够设置的参数,也只是端到端协议之上的网络层和传输层。
2 基于 TCP 协议的 socket 介绍
2.1 创建一个 TCP 服务的步骤
- 创建一个 socket。
- 调用 bind 函数绑定一个特定的服务地址与端口。
其中端口是用来标定系统中对应的应用程序,IP地址是用来指定监听网卡,当然机器上可能存在多个网卡,可以选择监听所有网卡或某一个网卡。
- 调用 listen 指定网口进行监听。
绑定 IP 后就可以对该服务进行监听了,服务端开启监听后,则客户端就可以发起连接,与服务端进行 三次握手创建连接。
- 调用 accept 函数,拿出一个已经完成的连接进行(已连接 socket)数据处理。
accept 调用是从 监听 socket 的已连接队列中取一个 establish 状态的 socket 返回。
Note:
此处在内核中,OS 为每个监听 socket 维护了两个队列,一个是已经建立了连接的队列,这时候连接三次握手已经完毕,处于 establish 状态;一个是还没有完全建立连接的队列,此时三次握手还没完成,处于 syn_rcvd 的状态。 - 连接成功建立后,就进行读写操作。
2.2 socket 的实现
说 TCP 的 socket 是一个文件流是非常准确的,因为在 Linux 内核中,socket 就是一个文件描述符,可以承载对于文件的大部分操作。
众所周知,在内核中,每一个进程都对应着一个 task_struct,在此结构体中存在一个文件描述符数组,来列出次进程的所有打开文件的文件描述符。文件描述符是一个整数,是这个数组的下标。这个数据的元素是一个指针,指向内核中所有打开的文件列表中对应的元素,既然是文件,就会有一个 inode,只是 socket 的 iNode 不像文件系统中 iNode,保存在硬盘中;这个 inode 是在 socket 调用时,在内存中创建的,指向内核中的 socket 结构。
在 socket 结构中,存在两个队列,一个发送队列,一个接收队列。这两个队列里面保存的是一个缓存 sk_buff。这个缓存里面能够看到完整包的结构。
![](https://i-blog.csdnimg.cn/blog_migrate/33a85879552b2d476566080ab5484429.png)
2.3 基于 UDP 协议的 socket 程序函数调用过程
UDP 是无连接的,所以不需要三次握手,也不需要调用 listen 和 connect。所以也不存在连接状态,sendto 和 recvfrom 的每一次调用都创建一对 socket。
3 服务器如何处理多客户连接
一个服务很显然是一对多的,通常一个服务都需要服务多个客户,但是服务的客户数肯定也是有限的,首先就让我们来看看理论上一个服务能够服务的客户数量是多少。如我们所知,任何一个连接都是由以下一个四元组构成:
{本机 ip,本机端口,对端 ip,对端端口}
对于特定服务来说,以上四元组中本机 ip 和本机端口是保持不变的,而对端 ip 和对端端口是可变的,根据 ip 和端口的数据类型可以知道最大可能对端数为2的32次方乘以2的16次方,为2的48次方。
当然,这是服务端理论上的最大可能并发数,实际上由于内存容量、文件描述符数量的限制,实际远远小于理论数量。从而需要我们尽可能提高服务器所能服务的客户端的数量。
3.1 多进程方式
从上述 tcp socket 的介绍中可以看出,每个客户端都会和服务端创建一对 socket,对于服务端称为此 socket 为已连接 socket,与监听 socket 是完全分离的。基于父子进程能够继承文件描述符的特性,可以考虑创建一个子进程专门来处理已连接 socket 与对应客户端的通信。子进程处理完成后就自行退出该进程,并通知父进程。
![](https://i-blog.csdnimg.cn/blog_migrate/29a699665ea72d9678624c7952c2b314.png)
3.2 多线程方式
线程为轻量级进程,线程共享所在进程的所有资源。可并行执行。显然可以新建线程来处理已连接 socket。
![](https://i-blog.csdnimg.cn/blog_migrate/29a7a70408f3f4cd7e9cc98eabf99730.png)
3.3 多路复用,轮询方式
以上两种方式,对于解决 10K 以下的用户都是够用的,但是对于 10K 以上的用户,其服务性能就急剧下降了。为了解决著名的 C10K 问题,提出多路复用技术。
由于 socket 是文件描述符,故我们考虑在某一个特定的线程中来管理所有的已连接 socket,将其放在一个文件描述符集合 fd_set 中,这就是进度墙,然后调用 select 函数来监听文件描述符集合是否变化。一旦变化,就会依次查看每个文件描述符。那些发生变化的文件描述符会在 fd_set 对应的位置 1,表示对应的 socket 可读或可写,从而进行读写操作,然后调用 select,监控下一轮变化。
3.4 多路复用,事件通知方式
上面 select 函数还是存在问题的,因为每次 socket 所在文件描述符集合有 socket 发生变化时,都需要通过轮询的方式,也就是需要过一遍所有的已连接 socket 看其是否有数据读或写。而文件描述符集合的大小FD_SIZE 就成了能监听 socket 的瓶颈。同时在轮询的过程中,对于不存在变化的描述符同样需要遍历,就读写数据本身来说也是无用功。
如果改为事件通知的方式,那就方便多了,如果某个文件描述符有数据读或写,自行通知线程进行读写操作。能完成这种事件通知的函数叫 epoll,它在内核中不是通过轮询的方式来实现的,而是通过注册 callback 函数的方式,当某个文件描述符发生变化的时候,就会主动通知。
select 与 epoll方式实现比较:
- select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。
- select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次。而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个用于epoll文件描述符的等待队列)。这也能节省不少的开销。
![](https://i-blog.csdnimg.cn/blog_migrate/f5bc25a8611469854b5c5e3838c901b0.png)
如上图,假设进程打开了 socket m n x等多个文件描述符,现在需要通过 epoll 来监听是否这些 socket 都有事件发生。其中 epoll_create创建一个 epoll 对象,也是一个文件,也对应一个文件描述符,同样对应着打开文件列表中的一项。在这项里面有一个红黑树,在红黑树中,要保存这个 epoll 所监听的所有套接字。
当 epoll_ctl 添加一个 socket 时,其实就是添加一个 socket 到该红黑树中,同时红黑树里面的节点指向一个结构,该结构在被监听的 socket 的事件列表中。当一个 socket 来了一个事件的时候,可以从这个列表中得到 epoll 对象,并用 call_back 通知它。
注意:epoll 也是现在解决 C10K 问题的利器。