飞道的博客

【操作系统三】图解网络IO(bio\nio\slect\epoll)

372人阅读  评论(0)

  • 简介
    上一章,我们讲解过了TCP/IP模型,本篇我们详细的介绍下网络通信中的io知识,包括bio、nio、slect、epoll。考虑到很多同学并非计算机专业出身,在讲解这些知识之前,我们还提前补充了计算机组成、系统中断、晶振的时间中断和事件中断等。

一、计算机组成

对于非计算机专业的同学,很多人并没有系统的学习过计算机操作系统和计算机网络等。比如冯诺依曼模型:中央处理器(CPU)、内存、输⼊设备、输出设备、总线。

对于上图,非计算机的同学理解起来还是太抽象。我们可以简单的把计算机简单的分为:cpu、内存、io设备。(如下图)

  • 在开机前,计算机内存是空的。内存分为用户空间、内核空间(也叫用户态和内核态)。
  • 内核也是一个程序?
    开机时,首先会把计算机的内核(kernel)加载到内存中。内核进入内存中时,首先会划分一个空间,就是内核空间。
    用户的进程存活在用户空间。
  • 为何要有内核?
    多个进程访问硬件,需要通过内核。内核托管了硬件。

二、系统中断

  • 用户程序直接访问内核空间?
    答:不可以。cpu有一个保护机制,开机时会划分出内核空间,受保护的。
  • 用户程序如何访问硬件?
    答:通过内核的系统调用。内核有一个系统调用,系统调用走的是软中断。此时,cpu会有一个从用户态到内核态的切换。内核里面,会有一些硬件的访问,封装的一些方法。而这些方法,通过中断的方式调取。(软中断)

三、晶振(时间中断、分时复用)

假如只有1个cpu,内核和系统进程间是怎么运行的?也就是说,假如一个进程不释放cpu,其他的怎么继续执行?

  • cpu内有一个晶振。
  • 从cpu通电开始,晶振一直在嗒嗒嗒的震动。(比如,1秒中震动1万次)
  • 计算机系统是分时的,一个时间片是多久。(假如,1个时间片是1毫秒)
  • 比如晶振1毫秒可以震动10次。(晶振震动10次会产生一个中断(时间中断)
  • 产生中断后。
    • 内核启动时,会在内核中埋一个进程调度的回调地址(callback)
    • 假如进程1的时间片用完了,pia的以下就会产生一个中断。(cpu可以执行其他进程了(包括内核))
    • 首先,把进程1的现场保护起来。
    • 然后,调用进程调度的回调地址,知道该调用哪个进程了,
    • 最后,把另外一个进程2(比如应该执行进程2了)加载起来。
    • 这样就可以执行进程2了,如此反复。

备注:

  • 进程间的的分时复用,依赖晶振的中断,成本较大。比如真的切换到进程2了,进程2也不一定要执行,也可能在阻塞。
  • cpu不能百分百作用在业务上,有可能50%的时间都在调度上。

四、事件中断

CPU假如只有一颗,比如,同时接收网卡数据、移动鼠标

1、DMA

DMA,全称Direct Memory Access,即直接存储器访问。

DMA传输将数据从一个地址空间复制到另一个地址空间,提供在外设和存储器之间或者存储器和存储器之间的高速数据传输。当CPU初始化这个传输动作,传输动作本身是由DMA控制器来实现和完成的。DMA传输方式无需CPU直接控制传输,也没有中断处理方式那样保留现场和恢复现场过程,通过硬件为RAM和IO设备开辟一条直接传输数据的通道,使得CPU的效率大大提高。

2、事件中断

  • 当io设备接收到数据时,会产生一个事件中断,等待cpu进行处理。

3、网卡也会产生中断?

  • 网卡也会产生中断?
    答:网卡也是一个io设备,也会产生中断事件。
  • DMA用来干啥?
    答:DMA就是内存中的一个区域,网卡的数据来了后,可以直接把数据存入DMA.
  • 有什么用?
    答:网卡来了数据后,可以直接存入DMA,不用每次都都产生一个cpu中断。
  • 为何不每次产生cpu中断?
    答:cpu内存寻址的速度是纳秒,io的操作都是毫秒。
    如果在cpu处理其他过程中,我们直接把数据放入dma,就可以告诉cpu,你去操作数据吧,这样cpu的效率就是纳秒。
    如果中间染指了io操作,那cpu就变成了毫秒。

五、linux系统知识

1、linux下一切皆文件?

1.1、nc启动一个服务端,端口号8080

  • 命令如下
nc -l 8080
  • 输出如下,监听成功。

  • 查看nc进程id,

ps -fe | grep nc
  • 如下图,进程id3671

1.2、linux下一切皆文件.

  • 根目录下,有一个文件夹proc。(开机才有,平时是空的)
cd /
ll
  • 输出如下,有一个存放进程的文件夹proc.

  • 进入proc目录,找到进程id为3671的文件夹,如下图

cd proc
ls
  • 输出如下,proc里面存的是所有的进程。
  • 进入进程3671的文件夹。进程抽象成了文件。
cd 3671
pwd
ls
  • 输出如下

1.3、程序有三个基本的流.

一个程序有三个基本的流:输入流、输出流、错误流。1、2、3是文件描述符。

  • 0:输入流
  • 1:输出流
  • 2:错误流
  • 3:socket的监听的文件描述符。
cd fd
pwd
ll
  • 输出如下

备注:今天就写到这里吧,后面的会基于此继续写。后面的nc的进程号可能会变。

1.4、监听和建立链接.

分别启动3个shell
1、使用nc启动一个服务端

nc -l 8080

2、使用nc启动一个客户端,和服务端建立链接,(如下图,分配的随机端口号为58438)

nc localhost 8080

3、查看建立的链接

netstat -natp
  • 输出如下

  • 查看客户端进程的fd目录如下

  • 问题:为何服务端下的fd有2个socket,而客户端下有1个socket.
    答:因为服务端除了建立链接,还需要监听,所以需要两个文件描述符。

2、linux系统文档

2.1、安装系统文档

yum instal man-pages

2.2、查看第二类的系统文档。

  • 系统文档一共有8类。第二类是系统调用。
  • 我们查看第二类的系统文档中的socket
man 2 socket
  • 输出如下图,系统调用是提供的c的调用方式。

    上面的图片往下拉下,可以看到,socket返回一个文件描述符。
RETURN VALUE
       On  success,  a  file  descriptor  for  the new socket is returned.  On
       error, -1 is returned, and errno is set appropriately.

我们获取socket的文件描述符干嘛呢?其实拿到文件描述符就可以操作了。比如使用read函数读文件描述符。

man 2 read
  • 输出如下

2.3、知识连接

socket返回的文件描述符,也是我们在1、linux下一切皆文件,中看到的,fd目录下的3和4。
所有 io抽象成了文件描述符。

2.4、linux系统函数给出的,创建socket的例子

在函数bind中,有完整的创建socket服务端的例子。

man 2 bind

输出中,有一个examle,从中看到,服务端socket流程,如下

  • 创建套接字(socket)
  • 绑定端口号(bind)
  • 监听(listen)
  • 接收数据(accept)
EXAMPLE
       An example of the use of bind() with Internet  domain  sockets  can  be
       found in getaddrinfo(3).

       The  following  example  shows  how to bind a stream socket in the UNIX
       (AF_UNIX) domain, and accept connections:

       #include <sys/socket.h>
       #include <sys/un.h>
       #include <stdlib.h>
       #include <stdio.h>
       #include <string.h>

       #define MY_SOCK_PATH "/somepath"
       #define LISTEN_BACKLOG 50

       #define handle_error(msg) \
           do {
      perror(msg); exit(EXIT_FAILURE); } while (0)

       int
       main(int argc, char *argv[])
       {
   
           int sfd, cfd;
           struct sockaddr_un my_addr, peer_addr;
           socklen_t peer_addr_size;

           sfd = socket(AF_UNIX, SOCK_STREAM, 0);
           if (sfd == -1)
               handle_error("socket");

           memset(&my_addr, 0, sizeof(struct sockaddr_un));
                               /* Clear structure */
           my_addr.sun_family = AF_UNIX;
           strncpy(my_addr.sun_path, MY_SOCK_PATH,
                   sizeof(my_addr.sun_path) - 1);

           if (bind(sfd, (struct sockaddr *) &my_addr,
                   sizeof(struct sockaddr_un)) == -1)
               handle_error("bind");

           if (listen(sfd, LISTEN_BACKLOG) == -1)
               handle_error("listen");

           /* Now we can accept incoming connections one
              at a time using accept(2) */

           peer_addr_size = sizeof(struct sockaddr_un);
           cfd = accept(sfd, (struct sockaddr *) &peer_addr,
                        &peer_addr_size);
           if (cfd == -1)
               handle_error("accept");

           /* Code to deal with incoming connection(s)... */

           /* When no longer required, the socket pathname, MY_SOCK_PATH
              should be deleted using unlink(2) or remove(3) */
       }

 

六、网络通信IO

1、什么是io?

i就是input,输入,o就是output,输出,合起来就是以流为基本的输入输出。

2、BIO

同步阻塞I/O处理(也就是BIO,Blocking I/O):

当客户端有请求到服务端的时候,服务端就会开启一个线程进行处理,当有多个请求进入时,就会开启多个线程分别处理对应的请求。

2.1、bio工作流程如下。

  • 第一步:建立socket链接、绑定端口号、监听。
  • 第二部:接收客户端的链接。
  • 第三步:接收客户端的数据。

2.2、bio的问题(read阻塞)

  • 如下图,当有新的客户端fd过来链接时。
  • 从1.1bio的流程可知,bio的read存在阻塞。

2.3、怎么解决bio的阻塞问题

  • 方案一:多线程。
    抛出一个线程,专门读。如上图,分别读fd4和fd5。

方案一的缺点:10万个链接,对接10万个线程。

  • 痛点:线程链接1对1.(因为阻塞)

  • 方案二:NIO

可以让读文件描述符,变成非阻塞的。读不到数据时,继续往后走。
不用抛出那么多线程了。

3、NIO

  • NIO:同步非阻塞式IO,(non-blocking IO、or java中也指的new IO)
  • read时没有数据时返回一个错误。不阻塞了。

优点:

  • 1、10完个链接不需要发出10万个线程了。一个线程就可以搞定了。(或者把接受和工作抛出两个线程)
  • 2、减少了cpu在几千几万个线程间的切换的事情了。
  • 3、cpu的利用率高了。

问题:

  • 1、每次read,都需要把cpu从用户状态,切换到内核状态。
  • 2、用户态和内核态切换,有些频繁。(假如有10万个链接,会有10万次的read,10万次的内核态和用户态的切换,假如这其中只有1个客户端发来了数据,那么会有99999次的read的浪费,也就是99999次内核态和用户态切换的浪费)
  • read的时间复杂度——O(n) ,时间复杂度n。其中n=10万。

怎么解决?

  • 多路复用

4、多路复用(slect)

  • 查看多路复用,系统调用的方法。
  • slect可以拿到10万个文件描述符中,真正有数据的文件描述符。
man 2 slect
  • 多路复用的流程图如下

优点:

  • slect调用了一次,就知道哪些文件描述符需要读。(比如5个)
  • 这样,我们调用5次read就可以了。
  • 把10万次的循环,压到了内核里面使用。
  • 真正的意义:没有那10万次的内核态和用户态的切换了。
  • read的时间复杂度——o(5),假如只有5个文件描述符需要读。

slect有什么弊端?

  • 1、没循环fd*,需要把10万次的文件描述符传给内核。
  • 2、内核每次都需要循环10万次,找哪些文件描述符需要读。

解决方案

  • 1、epoll

5、epoll

5.1、简介

  • 如《四、事件中断》介绍,io接收到数据后,就会产生一个中断,且放到了内存dma中了。内核只需要看下io数据属于哪个fd的,然后通知给程序就可以了。
  • 这样就是,内核被动的通过中断事件消耗这个事情。而不是主动的便利10万次。

查看epoll

man 7 epoll
  • 输出如下(可以看到,epoll属于7类,如果我们使用,需要调用3个二类系统调用的接口epoll_create、epoll_ctl、epoll_wait)

5.2、相关函数介绍

5.2.1、epoll_create

man 2 epoll_create
  • 可以看到这个函数的返回值是一个文件描述符,代表内核的一个空间。
  • 这样你就可以把所有的文件描述符放到内核的这个空间中了。
  • 怎么把所有的文件描述符放进去?
    答:通过epoll_ctl

5.2.2、eopll_ctl

man 2 epoll_ctl
  • 可以看到,把客户端来的文件描述符,放到epoll_create创建的空间中,及时来了10万了链接,我们只放一次就可以了。
  • 我们怎么知道那些客户端的数据到了呢?
    答:使用epoll_wait

5.2.2、eopll_wait

man 2 epoll_wait
  • 可以看到,假如我们设置了8个文件描述符的结构体,那么当io数据来了8个后,我们就可以产生一个中断事件。

  • epoll vs mmap
    问:epoll有使用mmap吗?
    答:前期epoll有用mmap,后面epoll没有用mmap

5.3、epoll的过程

前提:已经创建了socket监听,并绑定了端口号,获得了文件描述符fd3.

  • 第一步:创建epoll,获取内核空间的一个文件描述符,fd1,
  • 第二步:把监听的文件描述符fd3,加入到第一步创建的空间中。埋下接收链接的事件。
  • 第三步:等待链接,
  • 第四步:若收到了链接fd8,。
  • 第五步:把收到的客户端链接的描述符fd8,加入到epoll_create创建的空间中,埋下读数据的事件。
  • 第六步:若fd8收到了数据,就会把数据放到dma中,并会产生事件中断。

备注:

  • 循环的去epoll_wailt。
  • 相当于基于event的多路复用。
  • epoll只拿到了event,读数据还是要调用read.
  • 网卡把数据放到了dma,我们通过事件知道了里面有数据。仅仅把这个事件告诉了你,wait得到了事件。真正操作数据还是要读内核。

优点

  • 压榨计算资源到极致。

5.4、aio和epoll

  • aio:异步非阻塞,通过回调处理,没有read。(目前非内核提供)

七、知识互联:kafka用到的三个技术点

kafka我们之前介绍过,其实就是mq.(消息队列)

  • kafka整个过程
    • 第一步:客户端的数据到内核。
    • 第二部:kafka进程读取数据。
    • 第三步:kafka把数据存入硬盘

1、kafka写数据为何那么快?mmap

mmap 即 memory map,也就是内存映射

  • 实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read、write 等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。
  • 问:kafka写数据为何那么快?
    答:因为写数据采用了mmap,写数据不用系统调用write,写到内存的同时,就同时写到了内核和硬盘里面。少了一次系统调用write的过程。

    其他:
  • kafka配置文件,有个segment01 1G,共享内存就是1个G.硬盘和内核的空间也是1个G.
  • 如果segment01满了后,会再次创建segment02 ,也是1个G。

2、kafka读数据为何那么快?零拷贝

  • sendfile是在内核中实现的,不需要拷贝了。
  • 表示在两个文件描述符之间传输数据,它是在操作系统内核中操作的,避免了数据从内核缓冲区和用户缓冲区之间的拷贝操作,因此可以使用它来实现零拷贝。

3、kafka其实还用到了nio/epoll

  • nio和epoll参考之前的介绍。

七、知识互联:redis、ngnix

  • redis底层用的是epoll、单线程。
  • ngnix底层用的也是epoll、还有零拷贝(sendfile)

转载:https://blog.csdn.net/junxuezheng/article/details/128293066
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场