Linux 的内核将所有外部设备都看做一个文件来操作(一切皆文件),描述符就是一个数字,指向内核中的一个结构体(文件路径,数据区等一些属性)。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符,或者可以说对一个文件的读写操作会调用内核提供的系统命令,然后返回一个file descriptor(fd,文件描述符)。而对一个socket的读写也会有响应的描述符,称为socket fd(socket文件描述符),服务端接收到客户端连接就会创建一个文件描述符。
可能很多人对socket的认识是:通过socket可以实现一个简单的网络通信,并不知道它的设计思路和使用它时到底干了啥。
核心过程:
(1)成功建立连接。
(2)内核等待网卡数据到位。(这里涉及DMA技术)(这一步控制IO是否阻塞)
(2)内核缓冲区数据拷贝到用户空间(这里涉及mmap内存映射技术)。(这一步控制IO是否同步)
大致解释下DMA和mmap:
DMA技术:网卡和磁盘数据拷贝到内存这个过程,如果需要CPU参与的话会占用大量的CPU运行时间,因为IO操作相对耗时非常高。而且CPU主要适用于进行运算,磁盘数据拷贝到内存这个过程并不涉及到运算操作而且流程固定,因此设计了DMA来专门进行上述拷贝操作,相当于在磁盘嵌入了一个DMA芯片(类似简易版的IO cpu)让它来专门负责上述拷贝操作,从而使得CPU不参与上述拷贝操作,使之专注于运算操作。
mmap的设计理念是:用户空间和内核空间映射同一块内存空间,从而达到省略将数据从内核缓冲区拷贝到用户空间的操作,用户空间通过映射直接操作内核缓冲区的数据。零拷贝(就是数据不经过用户空间,直接在内核空间进行操作)就是利用的mmap技术来实现的。
现在操作系统都是采用虚拟存储器,那么对 32 位操作系统而言,它的寻址空间(虚拟存储空间)为 4G(2 的 32 次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核,保证内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对 linux 操作系统而言(以 32 位操作系统为例)
每个进程可以通过系统调用进入内核,因此,Linux 内核由系统内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有 4G 字节的虚拟空间。
略
略
什么是IO多路复用
IO 多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄
一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作
没有文件句柄就绪就会阻塞应用程序,交出CPU
一个线程解决多连接的问题:当服务端接收到客户端的连接后,就将这个fd放入服务端用户空间的fd[]里面,服务端由一个线程进行不间断的while循环,每次循环都去调用系统select/poll/epoll函数,将用户空间的fd[]复制到内核空间,内核负责轮询所有fd的可读/可写状态,当某个fd有数据到达了(数据准备就绪),就通知用户进程,在等待select函数响应之前进程是阻塞的,如果是调用epoll的话会先去判断Rdlist,如果Rdlist有值则函数返回,如果Rdlist没有值则进程继续阻塞并放弃CPU。多路复用在Linux内核代码迭代过程中依次支持了三种调用,即select、poll、epoll三种多路复用的网络I/O模型。
select
select调用过程
(1)使用copy_from_user从用户空间拷贝fd_set到内核空间。
(2)注册回调函数pollwait。
(3)遍历所有fd,调用其对应的poll方法(对于socket,这个poll方法是sock_poll,sock_poll根据情况会调用到tcp_poll,udp_poll或者datagram_poll)。
(4)以tcp_poll为例,其核心实现就是pollwait,也就是上面注册的回调函数。
(5)__pollwait的主要工作就是把current(当前进程)挂到(网卡)设备的等待队列中,不同的设备有不同的等待队列,对于tcp_poll来说,其等待队列是sk->sk_sleep(注意把进程挂到等待队列中并不代表进程已经睡眠了)。在设备收到一条消息(网络设备)或填写完文件数据(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,这时current便被唤醒了。
(6)poll方法返回时会返回一个描述读写操作是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。
(7)如果遍历完所有的fd,还没有返回一个可读写的mask掩码,则会调用schedule_timeout是调用select的进程(也就是current)进入睡眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程。如果超过一定的超时时间(schedule_timeout指定),还是没人唤醒,则调用select的进程会重新被唤醒获得CPU,进而重新遍历fd,判断有没有就绪的fd。
(8)把fd_set从内核空间拷贝到用户空间。
经由这些步骤,当进程被唤醒后,它知道至少有一个 Socket 接收了数据。程序只需遍历一遍 Socket 列表,就可以得到就绪的 Socket。
总结:
select函数监视的fd分3类,分别是writefds、readfds、和 exceptfds。调用后select函数会阻塞,直到有fd就绪(有数据可读、可写、或者有except),或者超时(timeout 指定等待时间,如果立即返回设为 null 即可),函数返回。当select函数返回后,可以通过遍历fd[],来找到就绪的fd。select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。select的一个最大的缺陷就是单个进程对打开的fd是有一定限制的,它由FD_SETSIZE限制,默认值是1024,如果修改的话,就需要重新编译内核,不过这会带来网络效率的下降。
select缺点
select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。这样所带来的缺点是:
(1)单个进程所打开的FD是有限制的,通过 FD_SETSIZE 设置,默认1024 ;
(2)每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大;
(3)需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大
(4)进程被唤醒后,只能知道有socket接收到了数据,无法知道具体是哪一个socket接收到了数据,所以需要用户进程进行遍历,才能知道具体是哪个socket接收到了数据。
(5)对 socket 扫描时是线性扫描,采用轮询的方法,效率较低(高并发),当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的。
select为什么在socket连接很多的情况下性能不佳?
将维护socket监控列表和阻塞进程的操作合并在了一起,每次select()调用都会触发这两个操作,从而导致每次调用select()都需要把全量的fd[]列表从用户空间传递到内核空间,内核线程在阻塞进程前需要遍历fd[]将待阻塞的进程放入到每个fd的等待队列里(第一次遍历)。当有网络数据到来时,并不知道网络数据属于具体哪个socket,只知道收到过网络数据,因此需要遍历fd[]唤醒等待队列里的阻塞进程(第二次遍历),并且把fd[]从内核空间拷贝到用户空间,让用户程序自己去遍历fd[]判别哪个socket收到了网络数据(第三次遍历,发生在用户空间)。fd[]比较大的情况,大量的遍历操作会导致性能急剧下降,所以select会默认限制最大文件句柄数为1024,间接控制fd[]最大为1024。
poll
poll其实内部实现基本跟select一样,区别在于它们底层组织fd[]的数据结构不太一样,poll的底层数据结构是链表,从而实现了poll的最大文件句柄数量限制去除了
epoll
epoll的设计思路
将添加等待队列和阻塞进程拆分成两个独立的操作,不用每次都去重新维护等待队列,先用 epoll_ctl 维护等待队列,再调用 epoll_wait 阻塞进程。epoll还引入了eventpoll这个中间结构,它通过红黑树(rbr)来组织所有待监控的socket对象,实现高效的查找,删除和添加。当收到网络数据时,会触发对应的fd的回调函数,这时不是去遍历各个fd的等待队列进行唤醒进程的操作了,而是把收到数据的socket加入到就绪列表(底层是一个双向链表)。eventpoll有个单独的等待队列来维护待唤醒的进程,避免了像select那样每次需要遍历fd[]来查找各个fd的等待队列的进程。
epoll 的整个工作流程
xxxxxxxxxx
31epoll_create(); // 内核中间加一个 ep 对象,把所有需要监听的 socket 都放到 ep 对象中
2epoll_ctl(); // epoll_ctl 负责把 socket 增加、删除到内核红黑树
3epoll_wait();// epoll_wait 负责检测可读队列,没有可读 socket 则阻塞进程
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关,每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为红黑树元素个数)。而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。当程序执行到 epoll_wait 时,如果 Rdlist 已经引用了 Socket,那么 epoll_wait 直接返回,如果 Rdlist 为空,阻塞进程。Rdlist就是所谓的就绪列表,
epoll的两种工作方式
1.水平触发(LT)2.边缘触发(ET)
水平触发 (level-trggered)
只要文件描述符关联的读内核缓冲区非空,有数据可以读取,就一直发出可读信号进行通知,当文件描述符关联的内核写缓冲区不满,有空间可以写入,就一直发出可写信号进行通知 LT 模式支持阻塞和非阻塞两种方式。epoll 默认的模式是 LT。LT模式:若就绪的事件一次没有处理完要做的事件,每次 epoll_wait 都会返回它的事件,提醒用户程序去操作,就会一直去处理。即就会将没有处理完的事件继续放回到就绪队列之中(即那个内核中的链表),一直进行处理。
边缘触发 (edge-triggered)
当文件描述符关联的读内核缓冲区由空转化为非空的时候,则发出可读信号进行通知,当文件描述符关联的内核写缓冲区由满转化为不满的时候,则发出可写信号进行通知。两者的区别在哪里呢?水平触发是只要读缓冲区有数据,就会一直触发可读信号,而边缘触发仅仅在空变为非空的时候通知一次。ET模式:就绪的事件只能处理一次,若没有处理完会在下次的其它事件就绪时再进行处理。而若以后再也没有就绪的事件,那么剩余的那部分数据也会随之而丢失。
LT(leveltriggered) 是缺省的工作方式,并且同时支持 block 和 no-blocksocket. 在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的 select/poll 都是这种模型的代表。
由此可见:ET模式的效率比LT模式的效率要高很多。只是如果使用ET模式,就要保证每次进行数据处理时,要将其处理完,不能造成数据丢失,这样对编写代码的人要求就比较高。 注意:ET模式只支持非阻塞的读写:为了保证数据的完整性。
进程调用系统io后,进程处理其他事务等待系统检测到缓冲区有数据,发送信号给进程,进程收到信号后挂起等待系统将数据拷贝到进程用户空间(只在数据拷贝阶段阻塞)
进程调用系统io后,离开直到内核接收到数据并将数据从内核空间复制到进程的用户空间后,内核才给小进程发送信号,全程没有阻塞