这篇的内容相当重要的
一、最基本的 Socket 模型
Socket 的中⽂名叫作插⼝,双⽅要进⾏⽹络通信前,各⾃得创建⼀个 Socket,这相当于客户端和服务器都开了⼀个“⼝⼦”,双⽅读取和发送数据的时候都通过这个“⼝⼦”。这样⼀看,是不是觉得很像弄了⼀根⽹线,⼀头插在客户端,⼀头插在服务端,然后进⾏通信。
二、建立Socket的过程
创建 Socket 的时候,可以指定⽹络层使⽤的是 IPv4 还是 IPv6,传输层使⽤的是 TCP 还是UDP。这里以TCP举个例子
服务端⾸先调⽤ socket() 函数,创建⽹络协议为 IPv4,以及传输协议为 TCP 的 Socket ,接着调⽤ bind() 函数,给这个 Socket 绑定⼀个 IP 地址和端⼝,绑定这两个的⽬的是什么?
绑定端⼝的⽬的:当内核收到 TCP 报⽂,通过 TCP 头⾥⾯的端⼝号,来找到我们的应⽤程序,然后把数据传递给我们。
绑定 IP 地址的⽬的:⼀台机器是可以有多个⽹卡的,每个⽹卡都有对应的 IP 地址,当绑定⼀个⽹卡时,内核在收到该⽹卡上的包,才会发给我们;
绑定完 IP 地址和端⼝后,就可以调⽤ listen() 函数进⾏监听,此时对应 TCP 状态图中的listen ,如果我们要判定服务器中⼀个⽹络程序有没有启动,可以通过 netstat 命令查看对应的端⼝号是否有被监听。
服务端进⼊了监听状态后,通过调⽤ accept() 函数,来从内核获取客户端的连接,如果没有客户端连接,则会阻塞等待客户端连接的到来。
那客户端是怎么发起连接的呢?客户端在创建好 Socket 后,调⽤ connect() 函数发起连接,该函数的参数要指明服务端的 IP 地址和端⼝号,然后万众期待的 TCP 三次握⼿就开始了。
注意,监听的 Socket 和真正⽤来传数据的 Socket 是两个:
⼀个叫作监听 Socket;
⼀个叫作已连接 Socket;
连接建⽴后,客户端和服务端就开始相互传输数据了,双⽅都可以通过 read() 和write() 函数来读写数据。
三、如何服务更多的⽤户?
TCP Socket 调⽤流程是最简单、最基本的,它基本只能⼀对⼀通信,因为使⽤的是同步阻塞的⽅式。
基于Linux一切皆文件的概念,Socket 实际上是⼀个⽂件,也就会对应⼀个⽂件描述符。在 Linux 下,单个进程打开的⽂件描述符数是有限制的,没有经过修改的值⼀般都是 1024,不过我们可以通过 ulimit 增⼤⽂件描述符的数⽬;
每个 TCP 连接在内核中都有对应的数据结构,意味着每个连接都是会占⽤⼀定内存的;
四、多进程模型
基于最原始的阻塞⽹络 I/O, 如果服务器要⽀持多个客户端,其中⽐较传统的⽅式,就是使⽤多进程模型,也就是为每个客户端分配⼀个进程来处理请求。
服务器的主进程负责监听客户的连接,⼀旦与客户端连接完成,accept() 函数就会返回⼀个「已连接 Socket」,这时就通过 fork() 函数创建⼀个⼦进程,实际上就把⽗进程所有相关的东⻄都复制⼀份,根据返回值来区分是⽗进程还是⼦进程,如果返回值是 0,则是⼦进程;如果返回值是其他的整数,就是⽗进程。正因为⼦进程会复制⽗进程的⽂件描述符,于是就可以直接使⽤「已连接Socket 」和客户端通信了。
当「⼦进程」退出时,如果不做好“回收”⼯作,就会变成僵⼫进程。有两种⽅式可以在⼦进程退出后回收资源,分别是调⽤ wait() 和 waitpid() 函数。
这种⽤多个进程来应付多个客户端的⽅式,当客户端数量⾼达⼀万时,肯定扛不住的,因为每产⽣⼀个进程,必会占据⼀定的系统资源,⽽且进程间上下⽂切换的“包袱”是很重的,性能会⼤打折扣。
五、多线程模型
既然进程间上下⽂切换的“包袱”很重,那我们就搞个⽐较轻量级的模型来应对多⽤户的请求—— 多线程模型。
因为线程共享些资源在上下⽂切换时是不需要切换,开销会小很多
当服务器与客户端 TCP 完成连接后,通过 pthread_create() 函数创建线程,然后将「已连接 Socket」的⽂件描述符传递给线程函数,接着在线程⾥和客户端进⾏通信,从⽽达到并发处理的⽬的。
这种方式还是有问题的。新到来⼀个 TCP 连接,就需要分配⼀个进程或者线程,那么如果要达到 C10K,意味着要⼀台机器维护 1 万个连接,相当于要维护 1万个进程/线程,操作系统就算死扛也是扛不住的。
六、I/O 多路复⽤
既然为每个请求分配⼀个进程/线程的⽅式不合适,那有没有可能只使⽤⼀个进程来维护多个Socket 呢?答案是有的,那就是 I/O 多路复⽤技术。
⼀个进程虽然任⼀时刻只能处理⼀个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉⻓来看,多个请求复⽤了⼀个进程,这就是多路复⽤,这种思想很类似⼀个 CPU 并发多个进程,所以也叫做时分多路复⽤。
select/poll/epoll 这是三个多路复⽤接⼝
select/poll
select 实现多路复⽤的⽅式是,将已连接的 Socket 都放到⼀个⽂件描述符集合,然后调⽤select 函数将⽂件描述符集合拷⻉到内核⾥,通过遍历⽂件描述符集合的⽅式,当检查到有事件产⽣后,将此 Socket 标记为可读或可写, 接着再把整个⽂件描述符集合拷⻉回⽤户态⾥,然后⽤户态还需要再通过遍历的⽅法找到可读或可写的 Socket,然后再对其处理。
需要进⾏ 2 次「遍历」⽂件描述符集合,⼀次是在内核态⾥,⼀个次是在⽤户态⾥ ,⽽且还会发⽣ 2 次「拷⻉」⽂件描述符集合,先从⽤户空间传⼊内核空间,由内核修改后,再传出到⽤户空间中。
所⽀持的⽂件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最⼤值为 1024
poll 和 select 并没有太⼤的本质区别,都是存储进程关注的 Socket集合,因此都需要遍历⽂件描述符集合来找到可读或可写的 Socket,⽽且也需要在⽤户态与内核态之间拷⻉⽂件描述符集合,这种⽅式随着并发数上来,性能的损耗会呈指数级增⻓。
很明显发现,select 和 poll 的缺陷在于,当客户端越多,也就是 Socket 集合越⼤,Socket集合的遍历和拷⻉会带来很⼤的开销,因此也很难应对 C10K。
epoll
epoll 通过两个⽅⾯,很好解决了 select/poll 的问题。
第⼀点,epoll 在内核⾥使⽤红⿊树来跟踪进程所有待检测的⽂件描述字,这样就不需要像 select/poll 每次操作时都传⼊整个 socket 集合,只需要传⼊⼀个待检测的 socket,减少了内核和⽤户空间⼤量的数据拷⻉和内存分配。
第⼆点, epoll 使⽤事件驱动的机制,内核⾥维护了⼀个链表来记录就绪事件,只会返回有事件发⽣的⽂件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,⼤⼤提⾼了检测的效率。
epoll 的⽅式即使监听的 Socket 数量越多的时候,效率不会⼤幅度降低,能够同时监听的Socket 的数⽬也⾮常的多了,上限就为系统定义的进程打开的最⼤⽂件描述符个数。因⽽,epoll 被称为解决 C10K 问题的利器。