网站开发所用的技术,谷歌广告推广,思维导图模板免费下载,网站 改版方案最基本的 Socket 模型
要想客户端和服务器能在网络中通信#xff0c;那必须得使用 Socket 编程#xff0c;它是进程间通信里比较特别的方式#xff0c;特别之处在于它是可以跨主机间通信。
Socket 的中文名叫作插口#xff0c;咋一看还挺迷惑的。事实上#xff0c;双方要…最基本的 Socket 模型
要想客户端和服务器能在网络中通信那必须得使用 Socket 编程它是进程间通信里比较特别的方式特别之处在于它是可以跨主机间通信。
Socket 的中文名叫作插口咋一看还挺迷惑的。事实上双方要进行网络通信前各自得创建一个 Socket这相当于客户端和服务器都开了一个“口子”双方读取和发送数据的时候都通过这个“口子”。这样一看是不是觉得很像弄了一根网线一头插在客户端一头插在服务端然后进行通信。
创建 Socket 的时候可以指定网络层使用的是 IPv4 还是 IPv6传输层使用的是 TCP 还是 UDP。
UDP 的 Socket 编程相对简单些这里我们只介绍基于 TCP 的 Socket 编程。
服务器的程序要先跑起来然后等待客户端的连接和数据我们先来看看服务端的 Socket 编程过程是怎样的。
服务端首先调用 socket() 函数创建网络协议为 IPv4以及传输协议为 TCP 的 Socket 接着调用 bind() 函数给这个 Socket 绑定一个 IP 地址和端口绑定这两个的目的是什么
绑定端口的目的当内核收到 TCP 报文通过 TCP 头里面的端口号来找到我们的应用程序然后把数据传递给我们。绑定 IP 地址的目的一台机器是可以有多个网卡的每个网卡都有对应的 IP 地址当绑定一个网卡时内核在收到该网卡上的包才会发给我们
绑定完 IP 地址和端口后就可以调用 listen() 函数进行监听此时对应 TCP 状态图中的 listen如果我们要判定服务器中一个网络程序有没有启动可以通过 netstat 命令查看对应的端口号是否有被监听。 绑定完 IP 地址和端口后就可以调用 listen() 函数进行监听此时对应 TCP 状态图中的 listen如果我们要判定服务器中一个网络程序有没有启动可以通过 netstat 命令查看对应的端口号是否有被监听。 服务端进入了监听状态后通过调用 accept() 函数来从内核获取客户端的连接如果没有客户端连接则会阻塞等待客户端连接的到来。 那客户端是怎么发起连接的呢客户端在创建好 Socket 后调用 connect() 函数发起连接该函数的参数要指明服务端的 IP 地址和端口号然后万众期待的 TCP 三次握手就开始了。
在 TCP 连接的过程中服务器的内核实际上为每个 Socket 维护了两个队列
一个是「还没完全建立」连接的队列称为 TCP 半连接队列这个队列都是没有完成三次握手的连接此时服务端处于 syn_rcvd 的状态一个是「已经建立」连接的队列称为 TCP 全连接队列这个队列都是完成了三次握手的连接此时服务端处于 established 状态
当 TCP 全连接队列不为空后服务端的 accept() 函数就会从内核中的 TCP 全连接队列里拿出一个已经完成连接的 Socket 返回应用程序后续数据传输都用这个 Socket。
注意监听的 Socket 和真正用来传数据的 Socket 是两个
一个叫作监听 Socket一个叫作已连接 Socket
连接建立后客户端和服务端就开始相互传输数据了双方都可以通过 read() 和 write() 函数来读写数据。
至此 TCP 协议的 Socket 程序的调用过程就结束了整个过程如下图 看到这不知道你有没有觉得读写 Socket 的方式好像读写文件一样。
是的基于 Linux 一切皆文件的理念在内核中 Socket 也是以「文件」的形式存在的也是有对应的文件描述符。
文件描述符的作用是什么
每一个进程都有一个数据结构 task_struct该结构体里有一个指向「文件描述符数组」的成员指针。该数组里列出这个进程打开的所有文件的文件描述符。数组的下标是文件描述符是一个整数而数组的内容是一个指针指向内核中所有打开的文件的列表也就是说内核可以通过文件描述符找到对应打开的文件。
然后每个文件都有一个 inodeSocket 文件的 inode 指向了内核中的 Socket 结构在这个结构体里有两个队列分别是发送队列和接收队列这个两个队列里面保存的是一个个 struct sk_buff用链表的组织形式串起来。
sk_buff 可以表示各个层的数据包在应用层数据包叫 data在 TCP 层我们称为 segment在 IP 层我们叫 packet在数据链路层称为 frame。
你可能会好奇为什么全部数据包只用一个结构体来描述呢协议栈采用的是分层结构上层向下层传递数据时需要增加包头下层向上层数据时又需要去掉包头如果每一层都用一个结构体那在层之间传递数据的时候就要发生多次拷贝这将大大降低 CPU 效率。
于是为了在层级之间传递数据时不发生拷贝只用 sk_buff 一个结构体来描述所有的网络包那它是如何做到的呢是通过调整 sk_buff 中 data 的指针比如
当接收报文时从网卡驱动开始通过协议栈层层往上传送数据报通过增加 skb-data 的值来逐步剥离协议首部。当要发送报文时创建 sk_buff 结构体数据缓存区的头部预留足够的空间用来填充各层首部在经过各下层协议时通过减少 skb-data 的值来增加协议首部。
你可以从下面这张图看到当发送报文时data 指针的移动过程。 如何服务更多的用户
前面提到的 TCP Socket 调用流程是最简单、最基本的它基本只能一对一通信因为使用的是同步阻塞的方式当服务端在还没处理完一个客户端的网络 I/O 时或者 读写操作发生阻塞时其他客户端是无法与服务端连接的。
可如果我们服务器只能服务一个客户那这样就太浪费资源了于是我们要改进这个网络 I/O 模型以支持更多的客户端。
在改进网络 I/O 模型前我先来提一个问题你知道服务器单机理论最大能连接多少个客户端
相信你知道 TCP 连接是由四元组唯一确认的这个四元组就是本机IP, 本机端口, 对端IP, 对端端口。
服务器作为服务方通常会在本地固定监听一个端口等待客户端的连接。因此服务器的本地 IP 和端口是固定的于是对于服务端 TCP 连接的四元组只有对端 IP 和端口是会变化的所以最大 TCP 连接数 客户端 IP 数×客户端端口数。
对于 IPv4客户端的 IP 数最多为 2 的 32 次方客户端的端口数最多为 2 的 16 次方也就是服务端单机最大 TCP 连接数约为 2 的 48 次方。
这个理论值相当“丰满”但是服务器肯定承载不了那么大的连接数主要会受两个方面的限制
文件描述符Socket 实际上是一个文件也就会对应一个文件描述符。在 Linux 下单个进程打开的文件描述符数是有限制的没有经过修改的值一般都是 1024不过我们可以通过 ulimit 增大文件描述符的数目系统内存每个 TCP 连接在内核中都有对应的数据结构意味着每个连接都是会占用一定内存的
那如果服务器的内存只有 2 GB网卡是千兆的能支持并发 1 万请求吗
并发 1 万请求也就是经典的 C10K 问题 C 是 Client 单词首字母缩写C10K 就是单机同时处理 1 万个请求的问题。
从硬件资源角度看对于 2GB 内存千兆网卡的服务器如果每个请求处理占用不到 200KB 的内存和 100Kbit 的网络带宽就可以满足并发 1 万个请求。
不过要想真正实现 C10K 的服务器要考虑的地方在于服务器的网络 I/O 模型效率低的模型会加重系统开销从而会离 C10K 的目标越来越远。
多进程模型
基于最原始的阻塞网络 I/O 如果服务器要支持多个客户端其中比较传统的方式就是使用多进程模型也就是为每个客户端分配一个进程来处理请求。
服务器的主进程负责监听客户的连接一旦与客户端连接完成accept() 函数就会返回一个「已连接 Socket」这时就通过 fork() 函数创建一个子进程实际上就把父进程所有相关的东西都复制一份包括文件描述符、内存地址空间、程序计数器、执行的代码等。
这两个进程刚复制完的时候几乎一模一样。不过会根据返回值来区分是父进程还是子进程如果返回值是 0则是子进程如果返回值是其他的整数就是父进程。
正因为子进程会复制父进程的文件描述符于是就可以直接使用「已连接 Socket 」和客户端通信了
可以发现子进程不需要关心「监听 Socket」只需要关心「已连接 Socket」父进程则相反将客户服务交给子进程来处理因此父进程不需要关心「已连接 Socket」只需要关心「监听 Socket」。
下面这张图描述了从连接请求到连接建立父进程创建生子进程为客户服务。 另外当「子进程」退出时实际上内核里还会保留该进程的一些信息也是会占用内存的如果不做好“回收”工作就会变成僵尸进程随着僵尸进程越多会慢慢耗尽我们的系统资源。
因此父进程要“善后”好自己的孩子怎么善后呢那么有两种方式可以在子进程退出后回收资源分别是调用 wait() 和 waitpid() 函数。
这种用多个进程来应付多个客户端的方式在应对 100 个客户端还是可行的但是当客户端数量高达一万时肯定扛不住的因为每产生一个进程必会占据一定的系统资源而且进程间上下文切换的“包袱”是很重的性能会大打折扣。
进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源还包括了内核堆栈、寄存器等内核空间的资源。
多线程模型
既然进程间上下文切换的“包袱”很重那我们就搞个比较轻量级的模型来应对多用户的请求 —— 多线程模型。
线程是运行在进程中的一个“逻辑流”单进程中可以运行多个线程同进程里的线程可以共享进程的部分资源比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等这些共享些资源在上下文切换时不需要切换而只需要切换线程的私有数据、寄存器等不共享的数据因此同一个进程下的线程上下文切换的开销要比进程小得多。
当服务器与客户端 TCP 完成连接后通过 pthread_create() 函数创建线程然后将「已连接 Socket」的文件描述符传递给线程函数接着在线程里和客户端进行通信从而达到并发处理的目的。
如果每来一个连接就创建一个线程线程运行完后还得操作系统还得销毁线程虽说线程切换的上写文开销不大但是如果频繁创建和销毁线程系统开销也是不小的。
那么我们可以使用线程池的方式来避免线程的频繁创建和销毁所谓的线程池就是提前创建若干个线程这样当由新连接建立时将这个已连接的 Socket 放入到一个队列里然后线程池里的线程负责从队列中取出「已连接 Socket 」进行处理。 需要注意的是这个队列是全局的每个线程都会操作为了避免多线程竞争线程在操作这个队列前要加锁。
上面基于进程或者线程模型的其实还是有问题的。新到来一个 TCP 连接就需要分配一个进程或者线程那么如果要达到 C10K意味着要一台机器维护 1 万个连接相当于要维护 1 万个进程/线程操作系统就算死扛也是扛不住的。
I/O 多路复用
既然为每个请求分配一个进程/线程的方式不合适那有没有可能只使用一个进程来维护多个 Socket 呢答案是有的那就是 I/O 多路复用技术。 一个进程虽然任一时刻只能处理一个请求但是处理每个请求的事件时耗时控制在 1 毫秒以内这样 1 秒内就可以处理上千个请求把时间拉长来看多个请求复用了一个进程这就是多路复用这种思想很类似一个 CPU 并发多个进程所以也叫做时分多路复用。
我们熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用进程可以通过一个系统调用函数从内核中获取多个事件。
select/poll/epoll 是如何获取网络事件的呢在获取事件时先把所有连接文件描述符传给内核再由内核返回产生了事件的连接然后在用户态中再处理这些连接对应的请求即可。
select/poll/epoll 这是三个多路复用接口都能实现 C10K 吗接下来我们分别说说它们。
select/poll
select 实现多路复用的方式是将已连接的 Socket 都放到一个文件描述符集合然后调用 select 函数将文件描述符集合拷贝到内核里让内核来检查是否有网络事件产生检查的方式很粗暴就是通过遍历文件描述符集合的方式当检查到有事件产生后将此 Socket 标记为可读或可写 接着再把整个文件描述符集合拷贝回用户态里然后用户态还需要再通过遍历的方法找到可读或可写的 Socket然后再对其处理。
所以对于 select 这种方式需要进行 2 次「遍历」文件描述符集合一次是在内核态里一个次是在用户态里 而且还会发生 2 次「拷贝」文件描述符集合先从用户空间传入内核空间由内核修改后再传出到用户空间中。
select 使用固定长度的 BitsMap表示文件描述符集合而且所支持的文件描述符的个数是有限制的在 Linux 系统中由内核中的 FD_SETSIZE 限制 默认最大值为 1024只能监听 0~1023 的文件描述符。
poll 不再用 BitsMap 来存储所关注的文件描述符取而代之用动态数组以链表形式来组织突破了 select 的文件描述符个数限制当然还会受到系统文件描述符限制。
但是 poll 和 select 并没有太大的本质区别都是使用「线性结构」存储进程关注的 Socket 集合因此都需要遍历文件描述符集合来找到可读或可写的 Socket时间复杂度为 O(n)而且也需要在用户态与内核态之间拷贝文件描述符集合这种方式随着并发数上来性能的损耗会呈指数级增长。
epoll
先复习下 epoll 的用法。如下的代码中先用epoll_create 创建一个 epoll对象 epfd再通过 epoll_ctl 将需要监视的 socket 添加到epfd中最后调用 epoll_wait 等待数据。
int s socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...)int epfd epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中while(1) {int n epoll_wait(...);for(接收到数据的socket){//处理}
}
epoll 通过两个方面很好解决了 select/poll 的问题。
第一点epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里红黑树是个高效的数据结构增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构所以 select/poll 每次操作时都传入整个 socket 集合给内核而 epoll 因为在内核维护了红黑树可以保存所有待检测的 socket 所以只需要传入一个待检测的 socket减少了内核和用户空间大量的数据拷贝和内存分配。
第二点 epoll 使用事件驱动的机制内核里维护了一个链表来记录就绪事件当某个 socket 有事件发生时通过回调函数内核会将其加入到这个就绪事件列表中当用户调用 epoll_wait() 函数时只会返回有事件发生的文件描述符的个数不需要像 select/poll 那样轮询扫描整个 socket 集合大大提高了检测的效率。
从下图你可以看到 epoll 相关的接口作用 在 Linux 内核中当发生事件时相关信息会被放入就绪链表中。这个过程是通过内核将事件信息复制到就绪链表中完成的而不是简单地取出或者移动已有的节点。
具体地说当事件发生时内核会在内部维护的数据结构中记录这些事件的相关信息然后将这些信息复制到就绪链表中。这样做的好处是就绪链表是一个用户空间与内核空间共享的数据结构应用程序可以直接访问这个链表而不需要直接操作内核的数据结构从而确保了安全性和稳定性。
总之事件信息是在内核和用户空间之间通过复制操作传递的而不是简单地取出或者移动已有的节点。
epoll 的方式即使监听的 Socket 数量越多的时候效率不会大幅度降低能够同时监听的 Socket 的数目也非常的多了上限就为系统定义的进程打开的最大文件描述符个数。因而epoll 被称为解决 C10K 问题的利器。
插个题外话网上文章不少说epoll_wait 返回时对于就绪的事件epoll 使用的是共享内存的方式即用户态和内核态都指向了就绪链表所以就避免了内存拷贝消耗。
这是错的看过 epoll 内核源码的都知道压根就没有使用共享内存这个玩意。你可以从下面这份代码看到 epoll_wait 实现的内核代码中调用了 __put_user 函数这个函数就是将数据从内核拷贝到用户空间。
边缘触发和水平触发
epoll 支持两种事件触发模式分别是边缘触发edge-triggeredET和水平触发level-triggeredLT。
这两个术语还挺抽象的其实它们的区别还是很好理解的。
使用边缘触发模式时当被监控的 Socket 描述符上有可读事件发生时服务器端只会从 epoll_wait 中苏醒一次即使进程没有调用 read 函数从内核读取数据也依然只苏醒一次因此我们程序要保证一次性将内核缓冲区的数据读取完使用水平触发模式时当被监控的 Socket 上有可读事件发生时服务器端不断地从 epoll_wait 中苏醒直到内核缓冲区数据被 read 函数读完才结束目的是告诉我们有数据需要读取
举个例子你的快递被放到了一个快递箱里如果快递箱只会通过短信通知你一次即使你一直没有去取它也不会再发送第二条短信提醒你这个方式就是边缘触发如果快递箱发现你的快递没有被取出它就会不停地发短信通知你直到你取出了快递它才消停这个就是水平触发的方式。
这就是两者的区别水平触发的意思是只要满足事件的条件比如内核中有数据需要读就一直不断地把这个事件传递给用户而边缘触发的意思是只有第一次满足条件的时候才触发之后就不会再传递同样的事件了。
如果使用水平触发模式当内核通知文件描述符可读写时接下来还可以继续去检测它的状态看它是否依然可读或可写。所以在收到通知后没必要一次执行尽可能多的读写操作。
如果使用边缘触发模式I/O 事件发生时只会通知一次而且我们不知道到底能读写多少数据所以在收到通知后应尽可能地读写数据以免错失读写的机会。因此我们会循环从文件描述符读写数据那么如果文件描述符是阻塞的没有数据可读写时进程会阻塞在读写函数那里程序就没办法继续往下执行。所以边缘触发模式一般和非阻塞 I/O 搭配使用程序会一直执行 I/O 操作直到系统调用如 read 和 write返回错误错误类型为 EAGAIN 或 EWOULDBLOCK。
一般来说边缘触发的效率比水平触发的效率要高因为边缘触发可以减少 epoll_wait 的系统调用次数系统调用也是有一定的开销的的毕竟也存在上下文的切换。
select/poll 只有水平触发模式epoll 默认的触发模式是水平触发但是可以根据应用场景设置为边缘触发模式。
多路复用 API 返回的事件并不一定可读写的如果使用阻塞 I/O 那么在调用 read/write 时则会发生程序阻塞因此最好搭配非阻塞 I/O以便应对极少数的特殊情况。
面试题
前两天在面试时候面试官问了我一个问题在多线程的环境下面使用epoll需要注意一些什么
当时我在想epoll不就是为了解决多线程或者是多进程的环境下资源调度浪费过大的问题吗没有想过为什么会有多线程的环境所以没打出来。现在想想估计是为了问多线程应该注意什么
多线程环境下要注意 线程安全性由于 epoll 实例可以在多个线程中使用因此需要确保对 epoll 实例的操作是线程安全的可以通过加锁等方式实现。 资源管理需要注意及时释放不再需要的资源例如关闭套接字、释放线程等以避免资源泄漏问题。 错误处理需要适当地处理可能出现的错误情况例如套接字连接失败、接收数据失败等以确保程序的稳定性和可靠性。 性能优化尽量减少 epoll_wait() 的阻塞时间可以使用超时参数避免长时间阻塞或者考虑使用边缘触发模式以提高性能。 合理的事件处理需要合理地处理事件例如新连接到达时需要将客户端套接字加入 epoll 实例中并启动新的线程处理连接而不是在主循环中直接处理连接。 适当的调试和测试在使用 epoll 时需要进行充分的调试和测试以确保程序的正确性和稳定性尤其是在多线程环境中。