精华内容
下载资源
问答
  • 答应我,这次搞懂 I/O 多路复用

    千次阅读 多人点赞 2021-04-12 13:46:45
    这次,我们以最简单 socket 网络模型,一步一步的过度到 I/O 多路复用。 但我不会具体细节说到每个系统调用的参数,这方面书上肯定比我说的详细。 好了,发车! 最基本的 Socket 模型 要想客户端和服务器能在网络...

    这次,我们以最简单 socket 网络模型,一步一步的过度到 I/O 多路复用。

    但我不会具体细节说到每个系统调用的参数,这方面书上肯定比我说的详细。

    好了,发车!


    最基本的 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 命令查看对应的端口号是否有被监听。

    服务端进入了监听状态后,通过调用 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 也是以「文件」的形式存在的,也是有对应的文件描述符。

    PS : 下面会说到内核里的数据结构,不感兴趣的可以跳过这一部分,不会对后续的内容有影响。

    文件描述符的作用是什么?每一个进程都有一个数据结构 task_struct,该结构体里有一个指向「文件描述符数组」的成员指针。该数组里列出这个进程打开的所有文件的文件描述符。数组的下标是文件描述符,是一个整数,而数组的内容是一个指针,指向内核中所有打开的文件的列表,也就是说内核可以通过文件描述符找到对应打开的文件。

    然后每个文件都有一个 inode,Socket 文件的 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 通过两个方面,很好解决了 select/poll 的问题。

    第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删查一般时间复杂度是 O(logn),通过对这棵黑红树进行操作,这样就不需要像 select/poll 每次操作时都传入整个 socket 集合,只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。

    第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

    从下图你可以看到 epoll 相关的接口作用:

    epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll 被称为解决 C10K 问题的利器

    插个题外话,网上文章不少说,epoll_wait 返回时,对于就绪的事件,epoll使用的是共享内存的方式,即用户态和内核态都指向了就绪链表,所以就避免了内存拷贝消耗。

    这是错的!看过 epoll 内核源码的都知道,压根就没有使用共享内存这个玩意。你可以从下面这份代码看到, epoll_wait 实现的内核代码中调用了 __put_user 函数,这个函数就是将数据从内核拷贝到用户空间。

    好了,这个题外话就说到这了,我们继续!

    epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET水平触发(level-triggered,LT

    这两个术语还挺抽象的,其实它们的区别还是很好理解的。

    • 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
    • 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;

    举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。

    这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。

    如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。

    如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 readwrite)返回错误,错误类型为 EAGAINEWOULDBLOCK

    一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。

    select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。

    另外,使用 I/O 多路复用时,最好搭配非阻塞 I/O 一起使用,Linux 手册关于 select 的内容中有如下说明:

    Under Linux, select() may report a socket file descriptor as “ready for reading”, while nevertheless a subsequent read blocks. This could for example happen when data has arrived but upon examination has wrong checksum and is discarded. There may be other circumstances in which a file descriptor is spuriously reported as ready. Thus it may be safer to use O_NONBLOCK on sockets that should not block.

    我谷歌翻译的结果:

    在Linux下,select() 可能会将一个 socket 文件描述符报告为 “准备读取”,而后续的读取块却没有。例如,当数据已经到达,但经检查后发现有错误的校验和而被丢弃时,就会发生这种情况。也有可能在其他情况下,文件描述符被错误地报告为就绪。因此,在不应该阻塞的 socket 上使用 O_NONBLOCK 可能更安全。

    简单点理解,就是多路复用 API 返回的事件并不一定可读写的,如果使用阻塞 I/O, 那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况。


    总结

    最基础的 TCP 的 Socket 编程,它是阻塞 I/O 模型,基本上只能一对一通信,那为了服务更多的客户端,我们需要改进网络 I/O 模型。

    比较传统的方式是使用多进程/线程模型,每来一个客户端连接,就分配一个进程/线程,然后后续的读写都在对应的进程/线程,这种方式处理 100 个客户端没问题,但是当客户端增大到 10000 个时,10000 个进程/线程的调度、上下文切换以及它们占用的内存,都会成为瓶颈。

    为了解决上面这个问题,就出现了 I/O 的多路复用,可以只在一个进程里处理多个文件的 I/O,Linux 下有三种提供 I/O 多路复用的 API,分别是: select、poll、epoll。

    select 和 poll 并没有本质区别,它们内部都是使用「线性结构」来存储进程关注的 Socket 集合。

    在使用的时候,首先需要把关注的 Socket 集合通过 select/poll 系统调用从用户态拷贝到内核态,然后由内核检测事件,当有网络事件产生时,内核需要遍历进程关注 Socket 集合,找到对应的 Socket,并设置其状态为可读/可写,然后把整个 Socket 集合从内核态拷贝到用户态,用户态还要继续遍历整个 Socket 集合找到可读/可写的 Socket,然后对其处理。

    很明显发现,select 和 poll 的缺陷在于,当客户端越多,也就是 Socket 集合越大,Socket 集合的遍历和拷贝会带来很大的开销,因此也很难应对 C10K。

    epoll 是解决 C10K 问题的利器,通过两个方面解决了 select/poll 的问题。

    • epoll 在内核里使用「红黑树」来关注进程所有待检测的 Socket,红黑树是个高效的数据结构,增删查一般时间复杂度是 O(logn),通过对这棵黑红树的管理,不需要像 select/poll 在每次操作时都传入整个 Socket 集合,减少了内核和用户空间大量的数据拷贝和内存分配。
    • epoll 使用事件驱动的机制,内核里维护了一个「链表」来记录就绪事件,只将有事件发生的 Socket 集合传递给应用程序,不需要像 select/poll 那样轮询扫描整个集合(包含有和无事件的 Socket ),大大提高了检测的效率。

    而且,epoll 支持边缘触发和水平触发的方式,而 select/poll 只支持水平触发,一般而言,边缘触发的方式会比水平触发的效率高。


    参考资料
    1. https://www.zhihu.com/question/39792257
    2. https://journey-c.github.io/io-multiplexing/#25-io-multiplexing
    3. https://panqiincs.me/2015/08/01/io-multiplexing-with-epoll/

    推荐阅读

    图解 TCP 三次握手和四次挥手

    图解 TCP 内核参数

    展开全文
  • 文章目录1 Redis为什么是单线程的1.1 官方解释1.2 Redis单线程优势1.3 Redis 不仅仅是单线程1.4 Redis的性能瓶颈2 IO多路复用2.1 文件描述符和文件句柄2.2 什么是IO多路复用?2.3 Redis的IO多路复用3 多线程IO多路...

    1 Redis为什么是单线程的

    1.1 官方解释

    Redis官网–>Documentation–>Tutorials & FAQ–>FAQ中是这样解释的:

    It’s not very frequent that CPU becomes your bottleneck with Redis, as usually Redis is either memory or network bound. For instance, using pipelining Redis running on an average Linux system can deliver even 1 million requests per second, so if your application mainly uses O(N) or O(log(N)) commands, it is hardly going to use too much CPU.

    However, to maximize CPU usage you can start multiple instances of Redis in the same box and treat them as different servers. At some point a single box may not be enough anyway, so if you want to use multiple CPUs you can start thinking of some way to shard earlier.

    You can find more information about using multiple Redis instances in the Partitioning page.

    However with Redis 4.0 we started to make Redis more threaded. For now this is limited to deleting objects in the background, and to blocking commands implemented via Redis modules. For future releases, the plan is to make Redis more and more threaded.

    简单翻译理解就是,我们实际应用场景中使用Redis时,CPU成为瓶颈的情况并不常见,因为Redis通常是内存或网络受限的。例如,使用在普通Linux系统上运行的单线程Redis每秒甚至可以发送100万个请求,因此,如果您的应用程序主要使用O(N)或O(log(N))命令,它几乎不会占用太多CPU。
    当然,为了最大限度地提高CPU使用率,redis也提供了拓展的解决方案,那就是集群部署的方式,基于这点,我们在使用redis的时候也要尽早进行服务分片算法的设计。

    1.2 Redis单线程优势

    • CPU在切换线程的时候,有一个上下文切换时间,而这个上下文切换时间是非常耗时的比如一个CPU主频是 2.6GHz,这意味着每秒可以执行:2.6*10^9 个指令,换算每个指令的时间大概是0.38ns,而一次上下文切换,将近需要耗时2000ns。而这个时间内,CPU什么都干不了,只是做了保存上下文都动作!
    • 多线程的目的,就是通过并发的方式来提升I/O的利用率和CPU的利用率,但是Redis是基于内存的,CPU资源不是Redis的性能瓶颈多线程确实可以提升效率,原因是I/O操作可以分为两个阶段:即等待I/O准备就绪和真正操作I/O资源,在等待就绪阶段,线程是在“阻塞”着等待磁盘。但是提升I/O利用率,并不是只有采用多线程技术这一条路可以走,Redis底层是基于IO多路复用来实现的;
    • 单线程不涉及加锁和解锁等复杂的操作,这块也能节约一定的性能;

    1.3 Redis 不仅仅是单线程

    我们所说的Redis单线程,指的是"其网络IO和键值对读写是由一个线程完成的",Redis是基于Reactor模式(底层是IO多路复用,可以理解为事件分发)开发的网络事件处理器,这个处理器被称为文件事件处理器。它的组成结构为4部分:多个套接字、IO多路复用程序、文件事件分派器、事件处理器。因为文件事件分派器队列的消费是单线程的,所以Redis才叫单线程模型。但是,Redis实例还有其它线程,Redis 4.0 开始就有多线程的概念了,比如 Redis 通过多线程方式在后台删除对象、以及通过 Redis 模块实现的阻塞命令等。
    同时,Redis6.0开始针对数据包的接收和处理使用了多线程,核心的redis操作命令依然保持单线程。

    1.4 Redis的性能瓶颈

    • 机器内存大小,内存大小关系到Redis存储的数据量;
    • 网络带宽,Redis客户端执行一条命令分为四个过程:发送命令、命令排队、命令执行、返回结果,而其中发送命令+返回结果这一过程被称为Round Trip Time(RTT,往返时间),这个在redis基础和使用(二)–pipeline中也有介绍;
    • 如果带宽和内存足够,瓶颈还有可能在针对大value的同步IO读写,拷贝数据导致的消耗,这也是redis不建议使用查看所有keys的命令,因为可能导致长时间阻塞,具体的同步IO读写消耗在于两部分:
    1.从socket中读取请求数据,会从内核态将数据拷贝到用户态 (read调用)
    2.将数据回写到socket,会将数据从用户态拷贝到内核态 (write调用)
    这部分数据读写会占用大量的cpu时间,也直接导致了瓶颈。 如果能有多个线程来分担这部分消耗,那redis的吞吐量还能更上一层楼,这也是redis引入多线程IO的目的
    

    2 IO多路复用

    2.1 文件描述符和文件句柄

    文件描述符(File descriptor):在Linux系统中一切皆可以看成是文件,文件又可分为:普通文件、目录文件、链接文件和设备文件。文件描述符是内核为了高效管理已被打开的文件所创建的索引,其是一个非负整数(通常是小整数),用于指代被打开的文件,所有执行I/O操作的系统调用都通过文件描述符。如下图所示,每个进程会维护一份自己的文件描述符的数组,每次都会使用未被占用的最小的整数。

    文件句柄(file handles):也有叫文件流,文件指针,windows的叫法,C语言中使用文件指针做为I/O的句柄。文件指针指向进程用户区中的一个被称为FILE结构的数据结构。FILE结构包括一个缓冲区和一个文件描述符。而文件描述符是文件描述符表的一个索引,因此从某种意义上说文件指针就是句柄的句柄(在Windows系统上,文件描述符被称作文件句柄)。
    最后做个总结,其实文件句柄和文件描述符都对应的文件操作,或者IO操作相关的指针信息对应的对象封装,针对网络请求,磁盘操作,文件操作,都是通过文件句柄或者说文件描述符进行操作的。

    2.2 什么是IO多路复用?

    • IO 多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;
    • 一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;
    • 没有文件句柄就绪就会阻塞应用程序,交出CPU。

      其实逻辑就是,服务器端采用单线程通过 select/poll/epoll 等系统调用获取 fd 列表,遍历有事件的 fd 进行 accept/recv/send ,使其能支持更多的并发连接请求。
    
    // 伪代码描述
    while(true) {
      // 通过内核获取有读写事件发生的fd,只要有一个则返回,无则阻塞
      // 整个过程只在调用select、poll、epoll这些调用的时候才会阻塞,accept/recv是不会阻塞
      for (fd in select(fds)) {
        if (fd == listen_fd) {
            client_fd = accept(listen_fd)
            fds.append(client_fd)
        } elseif (len = recv(fd) && len != -1) { 
          // logic
        }
      }  
    
    

    2.3 select/poll/epoll比较

    • select它仅仅知道,有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流 ,找出能读出数据,或者写入数据的流,对他们进行操作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。同时,单个进程所打开的FD是有限制的,通过 FD_SETSIZE 设置,默认1024 ;
    • poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的/font>;
    • epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的 ,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))

    2.4 Redis的IO多路复用

    在Redis 中,每当一个套接字准备好执行连接应答、写入、读取、关闭等操作时,就会产生一个文件事件。Redis的IO多路复用程序的所有功能都是通过包装操作系统的IO多路复用函数库来实现的。每个IO多路复用函数库在Redis源码中都有对应的一个单独的文件。

    在这里插入图片描述
    具体的单线程Redis底层调度可以参考下面这张图片:
    在这里插入图片描述

    3 多线程IO多路复用

    3.1 产生背景

    单线程IO的处理流程以及要解决的问题都很明确,他的瓶颈上面结合Redis也有说到,读写会占用大量的cpu时间, 单线程会阻塞在这里,如果能有多个线程来分担这部分消耗,那Redis的吞吐量还能更上一层楼,这也是Redis6.0引入多线程IO的目的。

    3.2 Redis多线程实现思路

    Redis 6.0 只有在网络请求的接收和解析,以及请求后的数据通过网络返回给时,使用了多线程。而数据读写操作还是由单线程来完成的 ,这样就不会出现并发问题了。然后也能一定程度解决大数据量的读写导致线程的阻塞。

    3.3 Redis多线程具体实现

    • 用一组单独的线程专门进行 read/write socket读写调用 (同步IO)
    • 读回调函数中不再读数据,而是将对应的连接追加到可读clients_pending_read的链表
    • 主线程在beforeSleep中将IO读任务分给IO线程组
    • 主线程自己也处理一个IO读任务,并自旋式等IO线程组处理完,再继续往下
    • 主线程在beforeSleep中将IO写任务分给IO线程组
    • 主线程自己也处理一个IO写任务,并自旋式等IO线程组处理完,再继续往下
    • IO线程组要么同时在读,要么同时在写
    • 命令的执行由主线程串行执行(保持单线程)
    流程图可参考下面:

    在这里插入图片描述

    展开全文
  • Android提高代码复用

    2021-06-04 18:42:36
    下面,就由小言来介绍几种提高Android代码复用性的方法。(为了浅显易懂,都是举最简单的例子,假如里面有什么不对的,敬请提出改善)1、活用includeinclude中文翻译是包含包括的意思。最直接明显的运用的地方便是APP...

    对于初学者来说,当自己要实现相似的功能时候,总是复制粘贴。这样不仅增加了工作量,同时也造成了代码冗余等问题。下面,就由小言来介绍几种提高Android代码复用性的方法。(为了浅显易懂,都是举最简单的例子,假如里面有什么不对的,敬请提出改善)

    1、活用include

    include中文翻译是包含包括的意思。最直接明显的运用的地方便是APP的标题,因为在一个APP中,其标题的格局差不多一致,每次都要复制粘贴,多麻烦。现在就来介绍一下include的简单运用。

    首先,我们先举一个例子,例如在layout中创建一个名为include_title.xml的文件,其代码为:

    android:layout_width="match_parent"

    android:layout_height="50dp"

    android:background="@color/blue" >

    android:id="@+id/imgbtnback"

    android:layout_width="40dp"

    android:layout_height="fill_parent"

    android:background="@color/transparent"

    android:src="@drawable/fanhui" />

    android:id="@+id/tvtitle"

    android:layout_width="wrap_content"

    android:layout_height="wrap_content"

    android:layout_centerInParent="true"

    android:text="标题"

    android:textColor="@color/white"

    android:textSize="20dp" />

    然后在需要添加标题的xml文件中加上    这话便可将标题显示在当前页面中,例如:

    android:layout_width="match_parent"

    android:layout_height="match_parent"

    android:orientation="vertical" >

    2、使用extends

    在一些页面中,我们总是需要共用一些页面显示效果的功能,例如Toast,Dialog等等。在这时,我们可以将这些东西封装到一个Activity中,当某个Activity需要使用里面的功能的时候,只要直接去继承它们,然后调用相应的方法即可。以下为制作一个BaseActivity的例子代码:

    import android.app.Activity;

    import android.widget.Toast;

    public class BaseActivity extends Activity {

    /**

    * 弹出提示(int)

    *

    * @param intContent

    */

    public void showToast(int intContent) {

    Toast.makeText(getApplication(), intContent, Toast.LENGTH_SHORT).show();

    }

    /**

    * 弹出提示(String)

    *

    * @param intContent

    */

    public void showToast(String strContent) {

    Toast.makeText(getApplication(), strContent, Toast.LENGTH_SHORT).show();

    }

    }

    当我们需要使用Toast的时候,只要继承这个BaseActivity这个类,然后直接调用showToast(参数)方法便可以直接弹出Toast,是不是简单一些呢。

    3、类的封装

    在2中讲的是将页面显示的效果封装起来,而这里讲的是将功能代码封装起来。在一些时候,我们需要重复调用一个功能方法,是不是觉得复制粘贴很麻烦呢,在这时,我们只需要将其功能代码封装起来,供以后调用。这也就是MVC模式中的Model层。例如:

    我们新建一个名为Tools的Java类,其代码为:

    /**

    * 工具类

    *

    */

    public class Tools {

    public static void outPut(Object obj) {

    System.out.println(obj);

    }

    }

    在这时,假如我们想输出一个数据的时候,直接调用Tools.outPut(参数)方法便可输出数据。

    4、使用string.xml和color.xml

    开发一个APP的时候,我们难免会使用到一些颜色值或者文字,在这时,我们应该将其放在相对应的color.xml或string.xml文件中,这样不仅提高代码的复用性,而且也便于修改,而不用到时改点需求的时候,就要到处找出页面修改其颜色值和文字。

    例如:color.xml文件

    #00000000

    #000000

    #5DC0F8

    使用的时候:

    android:layout_width="wrap_content"

    android:layout_height="wrap_content"

    android:text="标题"

    android:textColor="@color/blue" />

    string.xml的就不举例了,估计大家都懂了。

    laugh.gif

    5、使用library

    当做项目做多的时候,就会发现,其实,很多功能或者效果什么的,都非常相似,在这时,我们就该收集一下那些基本代码,将其封装在一个library中,等到用时直接加载这个library,而不需要重新写。其实就是和导入开源框架一样。

    展开全文
  • 似乎从互联网起源,BIO、NIO 的话题就从未间断,无论是平时还是面试。那么他们到底是什么?希望你看完这个文章彻底理解这些概念,同时这边文章也使用 Java 代码实现一个 I/O 多路复用...

    似乎从互联网起源,BIO、NIO 的话题就从未间断,无论是平时还是面试。那么他们到底是什么?希望你看完这个文章彻底理解这些概念,同时这边文章也使用 Java 代码实现一个 I/O 多路复用的实例,最后到 I/O 原理。

    IO 是什么?

    首先要了解什么是 I/O,一次网络请求、一次磁盘读取都是I/O,所以可以泛泛的理解,数据需要通过媒介进行读取和写入都是IO。

    下面是一次网络内容读取的 I/O 示意图,数据从外设(网卡)到内核空间,到用户空间(JVM)最后到应用程序的一个过程,那么阻塞和非阻塞指的是哪里呢?

    我们换一个调用链路图说明一下阻塞的位置,第一张图解释的是 BIO,也就是同步阻塞 IO,Java 最早期的版本的 IO 就是这样实现的。当程序调用到读取IO的时候,同步阻塞住程序,直到数据从网卡写入内核空间,再写入用户空间才返回数据,程序才可以继续运行。等到 Java 1.4 版本以后就支持了 NIO,其实 NIO 解决的地方是从网卡到内核空间部分的阻塞,也就是说应用程序发送一个读取 IO 的请求,如果数据还没有从网卡写入内核空间,直接返回未就绪,这样就做到了不需要程序死等到结果。等到写入内核空间以后,程序继续读取数据,这时候才会阻塞程序,如下图。关于 *IO 如果还是不理解的话,可以看下这篇文章,讲解的还是比较生动。《第一次听人用男女关系讲 NIO,虽然有点污,但很好理解》

    IO 多路复用

    基本的 IO 了解了,那我们开始进入 IO 多路复用的理解。假设我们要实现一个在线聊天系统,一共有 100 人同时聊天,我们先不管“”多不多,需要同时支持 100 人我们需要怎么做?

    我们需要为每一个请求用户创建一个新的线程,才能同时支持多个用户同时使用,然鹅,随着用户越来越多,需要创建的线程也越来越多,频繁的上下文切换和线程的创建和销毁,对于系统性能影响非常大,具体的链路可以通过下面的图理解下。

    那么是不是可以去掉多线程环节,使用一个线程处理 N 个 IO 请求,没有上下文切换和线程的创建和销毁,程序是不是反而更快呢?这就是 IO 多路复用的思路。

    首先我们从字面上理解一下,多路复用的英文是 multiplexing,中文翻译为“复用”,其实简单的理解就是使用单个线程通过记录跟踪每一个流(I/O)的状态来同时管理多个I/O,以此来减少线程的创建和切换的开销,进而提高服务器的吞吐能力。那么这么解释下来就更容易理解了,看图一目了然。

    既然使用单线程解决多个 IO 的切换问题,那么必须不能阻塞,如果阻塞了还是需要串行的读取 IO,阻塞程序的,切换就没有意义了,所以 NIO 并完全等于IO多路复用,而是解决多路复用的必要条件“非阻塞“,这也回答了标题的问题。

    自己实现 IO 多路复用

    上文了解了什么是 IO 多路复用自己也就可以实现了,下面就是我用 Java 写的 IO 多路复用的代码,代码只是表达意思,有问题可以互相讨论。

    public void start() throws IOException {
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.bind(new InetSocketAddress(6789));
        serverSocketChannel.configureBlocking(false);
        while (true) {
            SocketChannel channel = serverSocketChannel.accept();
            register(channel);
            Set<SocketChannel> selectionKeys = selectedKeys();
            if (selectionKeys.size() != 0) {
                for (SocketChannel socketChannel : selectionKeys) {
                    handle(socketChannel);
                }
            }
        }
    }
    
    private void register(SocketChannel channel) {
        if (channel != null) {
            publicKeys.add(channel);
        }
    }
    
    private Set<SocketChannel> publicKeys = new HashSet<>();
    
    private Set<SocketChannel> selectedKeys() {
        Set<SocketChannel> publicSelectedKeys = new HashSet<>();
        for (SocketChannel fd : publicKeys) {
            if ((fd.validOps() & OP_READ) != 0) {
                publicSelectedKeys.add(fd);
            }
        }
        return publicSelectedKeys;
    }
    
    private void handle(SocketChannel socketChannel) throws IOException {
        ByteBuffer byteBuffer = ByteBuffer.allocate(512);
        StringBuilder sb = new StringBuilder();
        while (socketChannel.read(byteBuffer) > 0) {
            byteBuffer.flip();
            sb.append(StandardCharsets.UTF_8.decode(byteBuffer));
        }
        if (sb.length() > 0) {
            System.out.println("服务端收到消息:" + sb.toString());
        }
    }
    

    逻辑相对还是比较简单,直接介绍几个关键的信息。

    1. 定义 register 方法,当有新的连接进来直接添加到 publicKeys set 集合里面,这样就有了一个地方可以存放所有的 socket 连接。

    2. 定义 selectedKeys方法,用于获取所有 ready 状态的 socket,原理是使用 (fd.validOps() & OP_READ) != 0 遍历所有的在 publicKeys里面的 socket判断是否可以读取了,然后添加到 selectedKeys 结果集里面,因为 fd.validOps() 这个判断是非阻塞的,所以可以继续运营程序。

    3. 外层 while (true) 循环获取可以读取的 socket 从而实现 IO 多路复用逻辑。

    是不是逻辑很清晰?然后写一个超级简单的 Client 就可以测试验证了。

    public void start() throws IOException {
        SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress(6789));
        socketChannel.configureBlocking(false);
        Scanner scanner = new Scanner(System.in);
        while (scanner.hasNextLine()) {
            String request = scanner.nextLine();
            if (request != null && request.length() > 0) {
                socketChannel.write(StandardCharsets.UTF_8.encode(request));
            }
        }
    }
    

    系统实现

    好了,那么问题来了,自己实现的 IO 多路复用不就可以了吗?为啥网上不停的吹嘘 select、poll、epoll 啥的?到这里我们一起看下 select 的源码你就懂了。

    int select(
        int nfds,
        fd_set *readfds,
        fd_set *writefds,
        fd_set *exceptfds,
        struct timeval *timeout);
    

    我们简单的对这些参数讲解下

    1. nfds: 监控的文件描述符集里最大文件描述符加1,简单理解为需要循环遍历的文件数

    2. readfds/writefds/exceptfds:读/写/异常事件的监听描述符集合。

    3. timeout:阻塞的超时时间

    如果你想更深入研究下源码,可以直接到 https://www.kernel.org/ 下载源码,找到 fs/select.c 文件即可。

    话题回到最初,通过上面的参数我们了解到,系统层面的 select 的实现其实是更底层的实现了刚才我们写的循环遍历查找思路,只是直接在系统层面性能更高。

    那么 poll、epoll 又是什么呢?poll 也是操作系统提供的系统调用函数,它和 select 的主要区别就是,去掉了 select 只能监听 1024 个文件描述符的限制。epoll 则是最后的升级版本,程序调用 epoll 的时候不需要全量传递了,支持变化传递,同时不需要循环遍历,而是通过异步 IO 事件唤醒程序。

    所以到这里你明白了,其实无论是系统或者是直接自己写代码都是在解决单线程复用的问题,也就是我们文章说的 IO 多路复用。

    参考文章

    1. 你管这破玩意叫 IO 多路复用?

    2. Java 中 BIO、NIO、AIO 的区别?

    3. 为什么一个还没毕业的大学生能够把 IO 讲的这么好?

    4. 图解 | 深入揭秘 epoll 是如何实现 IO 多路复用的!


    展开全文
  • 文章中部分中文名为HI_KER自行翻译,非官方翻译,特此提醒。 文章主要参考了 Logisim-Help-Tutorial (Logisim自带教程) 上一篇文章:Logisim元件用法详解二:Gates 门...
  • 啥是RecyclerViewA flexible view for providing a limited ...恩,我的翻译一向不咋滴。。所以原文也放上了。RecyclerView网上很多文都说是用来取代ListView和GridView的,事实上RecyclerView的确可以做到ListVie...
  • 掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、...
  • 1、操作系统基础1-1 用户态与内核态1-2 系统调用1-3 File Descriptor 文件描述符1-4 简单跟踪程序的系统调用过程2、多路复用的流程3、调试NIO的系统调用三、章节总结 一、进阶篇:Netty封装了什么? ​ 之前整理的...
  • 翻译:调试接口默认是打开的,初始化功能(普通IO,串口等)先把默认的调试口关闭. 需要使用的函数 extern en_result_t PORT_DebugPortSetting(uint8_t u8DebugPort, en_functional_state_t enFunc); 下面是重点注意 ...
  • 搜索过程中发现国外很多网站引用的一篇文章写的不错,所以翻译过来。版权声明:可以任意转载,转载时请务必以超链接形式标明文章原始出处和作者信息及本声明问题:假设你正在写一个基于多台服务器的php应用,理想的...
  • 本文中把 Channel 翻译成信道,按照个人习惯也可以称作是通道、管道。Selector 的核心组件有 SelectableChannel、Selector、SelectionKey。多路复用选择器更多的是用在 Java 网络编程,网络编程的常用到就是 UDP / ...
  • 本篇文章给大家分享的内容是关于PHP实现系统编程之网络Socket及IO多路复用 ,有着一定的参考价值,有需要的朋友可以参考一下一直以来,PHP很少用于socket编程,毕竟是一门脚本语言,效率会成为很大的瓶颈,但是不能...
  • 这次,我们以最简单 socket 网络模型,一步一步的过度到 I/O 多路复用。但我不会具体细节说到每个系统调用的参数,这方面书上肯定比我说的详细。好了,发车!最基本的 Socket 模型...
  • IO多路复用技术是操作系统级的技术,也就是我们常说的底层原理。好多框架,中间件都是使用了IO多路复用技术,才使其具备更高的性能,比如我们经常使用的Redis、Nginx和我们耳熟能详的高性能通信框架Netty。本篇文章...
  • 最后,再看看epoll提供的2种玩法ET和LT,即翻译过来的边缘触发和水平触发。其实这两个中文名字倒也有些贴切。这2种使用方式针对的仍然是效率问题,只不过变成了epoll_wait返回的连接如何能够更准确些。 例如,我们...
  • 网络编程–多路复用器select、poll、epol,javaNIO原理和实现 之前已经说过了BIO模型的原理和实现,并根据其不足(阻塞,多线程资源消耗等),介绍了内核的升级实现了accpet和read不阻塞的方法,以及介绍了channel和...
  • I/O 多路复用

    2021-03-19 08:28:44
    这次,我们以最简单 socket 网络模型,一步一步的过度到 I/O 多路复用。 但我不会具体细节说到每个系统调用的参数,这方面书上肯定比我说的详细。 好了,发车! 最基本的 Socket 模型 要想客户端和服务器能在...
  • 设计模式之桥接模式、合成/聚合复用原则的使用
  • _MaskTex 时,保持原来的深度关系,那么简单的方式就是 复用之前的 depthBuffer 问题 上面 提到的 复用之前的 depthBuffer 中 遇到一些问题,如下代码 // jave.lin 2021/12/9 测试 深度复用伪代码 // bloom 后效类 ...
  • java 七大设计原则之迪米特原则,开闭原则,合成复用原则 ---文字代码相结合理解七大设计原则有哪些?为什么要使用七大设计原则?迪米特原则开闭原则合成复用原则**设计原则的核心思想** 七大设计原则有哪些? 单一职责...
  • 其中的一个极端就是直接的解释技术,另一个极端就是二进制翻译。解释包括取一条源指令,对其进行分析,执行需要的操作,再取下一条源指令这样一个循环过程,所有工作都是由软件完成的。另一方面,二进制解释试图分摊...
  • 学习Java编程-IO复用

    2021-03-04 05:37:17
    原标题:学习Java编程-IO复用对于服务器的并发处理能力,我们需要的是:每一毫秒服务器都能及时处理这一毫秒内收到的数百个不同TCP连接上的报文,与此同时,可能服务器上还有数以十万计的最近几秒没有收发任何报文的...
  • 这次,我们以最简单 socket 网络模型,一步一步的过度到 I/O 多路复用。 但我不会具体细节说到每个系统调用的参数,这方面书上肯定比我说的详细。 好了,发车! 最基本的 Socket 模型 要想客户端和服务器能在...
  • IO多路复用 IO多路复用其实这个词翻译过来直面意思会令人误解。 简单说来,在不用技巧的情况下。 Client端连接进来,我肯定要判断你有没有给我发信息。那么我不能在这阻塞等你,我阻塞了我怎么去再次调用accept。 ...
  • - 229 -校园英语 / 翻译研究利用计算机进行中英文翻译的探讨宁夏大学外国语学院/来友昊【摘要】随着近年来科技的快速发展,针对于传统的人工翻译方式,相关的研究人员已经可以使用计算机的相关翻译软件来对相关文字...
  • 从IO模型到协程(三) 多路复用之select、poll和epoll 从IO模型到协程(四) 用python实现一个多路复用程序 从IO模型到协程(五) python中的协程(coroutin...
  • 计算机翻译论文3000字_计算机翻译毕业论文范文模板计算机翻译论文3000字(一):基于语料库的计算机辅助翻译教学法探究论文摘要近年来,语料库技术与翻译实践和翻译研究的结合欣欣向荣,但将语料库技术引入计算机辅助...
  • 最后,再看看epoll提供的2种玩法ET和LT,即翻译过来的边缘触发和水平触发。其实这两个中文名字倒也有些贴切。这2种使用方式针对的仍然是效率问题,只不过变成了epoll_wait返回的连接如何能够更准确些。 例如,我们...

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 49,037
精华内容 19,614
关键字:

复用翻译