前言
OSI七层模型和TCP/IP四层模型在这里就不说了。
套接字(socket)是一个抽象层,应用程序可以通过它发送或接收数据,可对其进行像对文件一样的打开、读写和关闭等操作。套接字允许应用程序将IO插入到网络中,并与网络中的其他应用程序进行通信。网络套接字是IP地址与端口的组合。
Socket与TCP/IP应用层的关系:
- Socket是一种编程接口,使应用程序能够使用TCP/IP协议进行网络通信。
- 应用层协议是构建在Socket和TCP/IP之上的,用于实现不同应用程序之间的通信规则。应用层利用Socket接口与传输层通信,最终使用TCP/IP协议在网络上传输数据。
下图是应用层通过Socket接口与传输层通信,网络包的收发过程:
最基本的Socket模型
TCP协议的Socket程序的调用过程:
- 服务端调用
socket()
函数,创建网络协议为 IPv4,以及传输协议为 TCP 的 Socket - 服务端调用
bind()
函数,给这个 Socket 绑定一个 IP 地址和端口。 - 服务端调用
listen()
函数进行监听。 - 客户端在创建好 Socket 后,调用
connect()
函数发起连接。 - 客户端调用
accept()
函数,来从内核获取已经完成的客户端的连接。 - 客户端和服务端可以开始相互传输数据了,双方都可以通过
read()
和write()
函数来读写数据。
在connect()函数发起的时候就是三次握手的开始。
在TCP连接的过程中,内核是维护了两个队列:
- 一个是还没完全建立连接的队列,称为TCP半连接队列(图中的syn队列),这个队列都是没有完成三次握手的连接,此时服务端处于
syn_rcvd
的状态。 - 一个是已经建立连接的队列,称为TCP全连接队列(图中的accept队列),这个队列都是完成了三次握手的连接,此时服务端处于
established
状态;
如何让更多的客户端连接同时使用?
如果按照上面的TCP socket进行连接,那当一个客户端连接有网络IO或者发生阻塞时,那么其他的客户端就无法使用了。
题外话:
- 四元组:本机IP,本机端口,对端IP,对端端口
- 五元组:协议,本机IP,本机端口,对端IP,对端端口
那么,服务器单机理论最大TCP客户端连接数 = 客户端IP数 × 客户端端口数。
对于IPv4来说,客户端的IP数最多为2的32次方,客户端的端口数最多为2的16次方,也就是服务端单机最大TCP客户端连接数约为2的48次方。
上面只是理论值,实际上服务器的TCP连接数会受两个方面影响:
- 文件描述符,Socket实际上是一个文件,也就会对应一个文件描述符。在Linux下,单个进程打开的文件描述符数是有限制的,没有经过修改的值一般都是1024,不过我们可以通过ulimit增大文件描述符的数目。
- 系统内存,每个TCP连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的。
经典的C10k和C1000k的问题
C是Client单词首字母缩写,C10K就是单机同时处理1万个请求的问题,C1000K就是单机同时处理100万个请求的问题。
C10k
从物理资源上来说,对2GB内存和千兆网卡的服务器来说,同时处理1万个请求,只要每个请求处理占用不到200KB(2GB/10000)的内存和100Kbit(1000Mbit/10000)的网络带宽就可以。所以,物理资源很容易满足,其实就是网络IO模型的问题。
C1000k
从物理资源使用上来说,100万个请求需要大量的系统资源。每个请求还是按照占用内存200KB和占用带宽100Kbit来算,那就需要200Gb和100000Mbit带宽才行。而且大量的连接还会带来大量的中断请求、文件描述符大量占用等等这些软件上的问题。虽然还是可以在网络IO模型上优化,但是C1000k更关键的是借助硬件资源了。
网络IO模型
多进程模型
为每个客户端分配一个进程来处理请求。
服务器的主进程负责监听客户的连接,一旦与客户端连接完成。accept()
函数就会返回一个已连接Socket,这时就通过fork()
函数创建一个子进程,实际上就把父进程所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。
注意:
- 当子进程退出时,实际上内核里还会保留该进程的一些信息,也是会占用内存的,如果不做好回收工作,就会变成僵尸进程,随着僵尸进程越多,会慢慢耗尽我们的系统资源。
- 客户端达到一万以上,多进程模型会扛不住,因为每个进程都会占用一定的系统资源,并且进程之间的上下文切换也非常消耗系统资源。
多线程模型
既然进程间的上下文切换消耗系统资源大,那么轻量级的多线程模型就出来了。
线程是运行在进程中的一个逻辑流,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等。
当服务器与客户端TCP完成连接后,通过pthread_create()
函数创建线程,然后将已连接Socket的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。
但是如果来一个连接就创建一个线程,那线程完成之后的销毁还是得靠操作系统,频繁的创建和销毁也是非常消耗资源的,所以多线程模型就采用了以
线程池
的方式来避免频繁的创建和销毁线程,把已连接的socket放到队列里,让提前创建好的线程池里的线程从队列里取出已连接的socket去处理。
注意:
- 这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前都要加锁。
- 虽然说改善了上下文切换的资源消耗,但是还是如果服务器需要维护一万个进程或者线程,肯定是扛不住的。
IO多路复用
既然给每个请求都分配一个进程或者线程操作系统扛不住,那么使用一个进程维护多个socket的IO多路复用就出来了。
一个进程也是在任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程,这就是多路复用,这种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用。
内核有三种IO多路复用接口,分别是:
- select
- poll
- epoll
select和poll
select和poll实现多路复用的过程是:
- 用户态进程将已连接的 Socket 都放到一个文件描述符集合里。
- 用户态进程调用 select 函数或者 poll 函数将文件描述符集合拷贝到内核空间里。
- 内核遍历这个文件描述符集合,并把有IO事件发生的 Socket 标记为可读或者可写。
- 内核把文件描述符集合拷贝回用户空间。
- 用户态进程再次遍历这个文件描述符集合找到可读可写的Socket,然后进行处理(也就是通过read函数或者write函数去把内核准备好的内核空间数据拷贝到用户空间)。
注意:这里只要发生IO事件,无论这个IO事件是否已经在内核里把数据准备好了,内核都会在文件描述符集合里把对应的socket标记上。
select和poll的区别是:
- select使用固定长度的BitsMap来维护文件描述符集合,一般是内核中的
FD_SETSIZE
限制, 默认最大值为1024,也就是只能监听0到1023的文件描述符。 - poll使用的是动态数组,以链式的结构来维护文件描述符集合,从而打破select中对文件描述符的限制。
epoll
select和poll需要2次遍历文件描述符集合和2次拷贝文件描述符集合。在并发数大的时候,是非常影响性能的。
epoll用了两方面去解决select/poll的问题:
- epoll在内核里使用红黑树来跟踪所有的文件描述符。所以每次都只需要从用户空间把待检测的socket传到内核空间即可,不需要像select/poll一样每次都要把整个socket集合拷贝到内核里。减少了内核空间和用户空间来回拷贝集合的消耗。
红黑树是一个高效的数据结构,增删改查的时间效率肯定比集合的高。
- epoll使用了事件驱动的机制。内核里维护了一个链表来记录就绪事件,当监控的红黑树里某个socket有事件发生并完成准备好数据时,通过回调函数内核会将其加入到这个就绪事件列表中,然后用户进程只会从链表中获取有事件发生的文件描述符,不需要像select/poll那样遍历整个socket集合,减少了需要遍历整个集合的时间。
epoll实现多路复用的过程是:
- 使用epoll的用户进程首先会通过
epoll_create
函数在内核生成并维护一个监听socket文件描述符的红黑树。 - 当出现需要监听的socket时,用户进程通过
epoll_ctl
函数,把需要监听的socket文件描述符传到内核的红黑树中。同时也会针对这个socket注册一个回调函数。 - 当内核里监听socket文件描述符的红黑树有IO事件发生时,会通过回调函数,把这个socket的文件描述符复制到链表里。
- 用户进程调用
epoll_wait
函数把这个链表从内核空间拷贝到用户空间,用户进程遍历获取到里面可读可写的socket,然后进行处理(也就是通过read函数或者write函数去把内核准备好的内核空间数据拷贝到用户空间)。
注意:
- 这里也是只要发生IO事件,无论这个IO事件是否已经在内核里把数据准备好了,内核都会在文件描述符集合里把对应的socket标记上。
- 用户进程调用
epoll_wait
函数是阻塞的,它得等待内核返回IO事件发生的通知。
epoll 支持两种事件触发模式:
- 边缘触发:当被监控的Socket文件描述符上有可读写事件发生时,内核只会从
epoll_wait
中苏醒(返回)一次。因此我们程序要保证一次性将内核缓冲区的数据读取完。只要边缘触发模式下的epoll_wait
函数返回了,那么意味着至少有一个socket文件描述符是有IO事件触发的。 - 水平触发:当被监控的Socket文件描述符上有可读事件发生时,内核会不断地从
epoll_wait
中苏醒(返回),直到内核缓冲区数据被read
函数读完才结束,目的是告诉我们有数据需要读取。
注意:因为边缘触发模式IO事件发生时只返回一次,所以需要循环从socket文件描述符中读写数据。如果有一个socket文件描述符是阻塞的,也就是内核还没准备好数据,那进程就阻塞在这里了。所以边缘触发模式一般和非阻塞IO搭配使用。
epoll支持边缘触发和水平触发的方式,默认是水平触发。而select/poll只支持水平触发。一般而言,边缘触发模式会比水平触发模式的效率高。