内核角度看IO

# 计算机组成部分

2.接管
2.接管
CPU
CPU
3.加载用户程序文件
3.加载用户程序文件
用户空间
用户空间
磁盘
磁盘
内核程序
内核程序
1.读取
1.读取
内核空间
内核空间
Actor
Actor
系统调用,来回拷贝
系统调用,来回拷贝
Viewer does not support full SVG 1.1

以下几个问题需要弄明白

  1. 什么是内核
  2. 什么是系统调用
  3. 为什么内存区域需要划分用户空间和内核空间

  内核是操作系统的重要组成部分(废话),进程通过内核程序调用计算机硬件。系统调用就是用户空间程序调用内核程序的过程。内核 封装底层硬件操作,提供接口调用,从安全角度看,用户空间随意访问内核程序占用空间的数据是不安全的。
  上下文切换和中断,中断一般伴随着上下文切换,中断分为软中断和硬中断,什么是中断?cpu接收到来自硬件和软件的发生某事件的信号,中断会打断正在正常执行的程序,开始处理事件。

磁盘或块设备
磁盘或块设备
分区
分区
分区
分区
分区
分区
文件系统
文件系统
读取普通文件
读取普通文件
通过文件系统读取
通过文件系统读取
直接从块设备读取
直接从块设备读取
Buffer
Buffer
Cache
Cache
原始磁盘块存储
原始磁盘块存储
从磁盘读取文件页缓存
从磁盘读取文件页缓存
Viewer does not support full SVG 1.1

<<Unix网络编程>>一书中介绍了五种IO模型,每一种IO都是对前一种的升级优化,阻塞IO,非阻塞IO,多路复用IO,信号驱动IO以及异步IO。

程序指令如何运行
内存
内存
IO-Bridge
IO-Bridge
CPU
CPU
ALU
ALU
Register
Register
PC
PC
Cache
Cache
系统总线
系统总线
IO总线
IO总线
IO总线
IO总线
鼠标、键盘
鼠标、键盘
显卡=显示器
显卡=显示器
磁盘
磁盘
网卡
网卡
Viewer does not support full SVG 1.1

  1. ALU:执行运算
  2. Register:存放从缓存或者主存中
  3. PC:记录当前线程执行到哪一步指令
  4. Cache:弥补内存与cpu之间速度差异,L1-3.其中L3同一物理cpu的多核之间共享,L1和L2私有。
  5. 超线程:就是单ALu多组(寄存器+程序计数器),因为ALU速度最快

# Socket

# 模拟

Linux中一切皆文件,操作系统上socket的表现形式就是文件描述符,文件系统中有存放有表示进程打开的socket的文件描述符,具体在/proc/{pid}/fd下。 socket建立一定有唯一的四元组组成。

# 1.创建连接
exec 8<> /dev/tcp/www.chenjiwei.cn/80
# 2.请求
echo -e 'GET / HTTP/1.0\n' 1>&8
# 3.输出返回
cat 0<&8
## 4.查看socket
cd /proc/$$/fd
1
2
3
4
5
6
7
8
  1. url可以随意。建立与目标url的socket通信,8:数字可任意指定,<>:输入输出流
  2. 1表示输出,&8类似指针引用,写get请求到文件描述符为8的socket中
  3. 0:表示输入,从socket获取返回输出到控制台上
  4. $$:当前进程ID

# 过程

  1. socket 文件描述符
  2. bind 文件描述符到指定端口
  3. listen 监听这个文件描述符
  4. accept 返回新连接的客户端=fd

# tcp

定义:一种可靠的、面向连接的传输层协议。三次握手和四次挥手是不可分隔的最小单元。

# 线程

从linux内核来看,线程是通过系统调用clone函数,通过内核clone一个轻量级的线程(附属父进程)

# 阻塞IO(BIO)

应用程序
应用程序
DMA方式
DMA方式
数据接收缓冲区
数据接收缓冲区
数据准备阶段
数据准备阶段
数据拷贝阶段
数据拷贝阶段
Socket缓冲区
Socket缓冲区
内核拷贝数据返回
内核拷贝数据返回
没有数据
没有数据
有数据
有数据
经过中断,内核线程发送到缓冲区
经过中断,内核线程发送到缓冲区
1.系统调用read
1.系统调用read
阻塞:等待
阻塞:等待
数据拷贝线程等待
数据拷贝线程等待
非阻塞:直接返回错误
非阻塞:直接返回错误
内核
内核
Viewer does not support full SVG 1.1

# 阻塞读

系统调用read
系统调用read
用户线程进入内核态
用户线程进入内核态
检查内核空间接收缓冲区是否有数据
检查内核空间接收缓冲区是否有数据
数据?
数据?
用户线程开始将数据从内核拷贝到用户空间
用户线程开始将数据从内核拷贝到用户空间
用户线程进入阻塞状态
用户线程进入阻塞状态
系统调用返回
系统调用返回
自检数据是否到达缓冲区?
自检数据是否到达缓冲区?
内核唤醒用户线程
内核唤醒用户线程
进入就绪状态等待线程调度器分配cpu
进入就绪状态等待线程调度器分配cpu
Viewer does not support full SVG 1.1

# 阻塞写

系统调用send
系统调用send
空间充足?
空间充足?
yes
yes
no
no
系统调用返回
系统调用返回
用户线程切换至内核态
用户线程切换至内核态
拷贝发送数据到Socket
发送缓冲区
拷贝发送数据到Socket...
一次性写入全部发送数据到缓冲区
一次性写入全部发送数据到缓冲区
内核唤醒用户线程
内核唤醒用户线程
用户线程让出cpu
进入阻塞状态
用户线程让出cpu...
No
No
空间充足?
空间充足?
Viewer does not support full SVG 1.1

# 缺点

  1. 连接和线程一一对应,并发量大的情况,系统可能无法创建线程。
  2. 建立连接后,如果没有发送数据,服务端新建的线程就会处于阻塞状态,浪费系统资源。

# 场景

  1. 连接数少,并发低的业务场景

# 非阻塞IO(NIO)

NIO出现的目的是为了使用更少线程数来处理更多的连接。
  当用户线程处理连接的socket(编程意义上的),当缓冲区没有数据时,系统调用会直接返回错误标志,这样用户线程就能处理下一个socket,达到使用较少 线程处理更多连接的目的,实现的基础是内核支持failfast。
  不同于阻塞IO的阻塞写(一定要一次性梭哈,写完数据),非阻塞写的特点在于能写多少是多少,当缓冲区没有多余空间,用户线程就会返回缓冲区空间字节数给应用程序, 方便用户线程轮询写完剩余数据到缓冲区中。

# 场景优缺点

  1. 避免了每个连接每个线程
  2. 用户线程轮询去发起系统调用,用户线程就会不停在用户态和内核态之间不停切换,这种上下文切换在高并发下开销也是巨大的
  3. 无意义轮询读写没有发生变化的socket

# IO多路复用

  主要理解两个概念,多路和复用,多路就是处理多个连接,而复用就是使用更少的线程,其实这就是非阻塞IO的实现,但是NIO存在上文提到的缺点,为了 避免频繁的system call,可以把轮询socket的工作移交到内核,这就需要内核支持这样的操作,用户线程在用户态只用一次系统调用就可以了。

# select

用户态
用户态
内核态
内核态
标志
标志
0
0
0
0
0
0
0
0
...
...
0
0
下标
下标
0
0
1
1
2
2
3
3
...
...
1023
1023
1.重置fd数组作为参数
1.重置fd数组作为参数
FD数组,BitMap结构
FD数组,BitMap结构
SELECT
SELECT
2.遍历FD
2.遍历FD
3.内核遍历FD,找到IO就绪的FD
3.内核遍历FD,找到IO就绪的FD
4.设置IO就绪FD标志为1
4.设置IO就绪FD标志为1
进入阻塞
进入阻塞
直接修改的是原数组
直接修改的是原数组
5.用户线程遍历FD数组查找标志1
5.用户线程遍历FD数组查找标志1

IO模型之select

IO模型之select
Viewer does not support full SVG 1.1
  用户线程把各个连接上的FD放在一个BitMap数组结构中,下标为表示连接的文件描述符,下标对应的值,1表示该fd上有读写事件,0表示该fd上没有读写事件, 内核会轮询这个数组,设置socket上有读写事件的下标为1(这里修改的是原数组),然后完成系统调用解除阻塞状态,用户线程轮询数组做后续处理。所以select模型适合1k左右的并发
性能瓶颈

  1. 由于内核在原数组进行打标,用户线程每次系统调用前都要重置数组
  2. BitMap数组长度限定为1024,所以SELECT模型只能处理1024个连接
  3. 由于大多数网络连接都是不活跃的,内核每次遍历整个fd集合都会产生大量不必要的开销
  4. 内核不会单独返回就绪IO的集合,用户线程就需要自行遍历找出被内核打标的fd
  5. select系统调用并不是线程安全的

# poll

poll是select的改进版本,仅仅改进了select只能监听1024个文件描述符数量的限制。使用没有固定长度的数组替代固定长度为1024的bitmap,受限于操作系统文件描述符的限制, 在性能上没有没有作出改进,和select本质上没有多大差别。

# epoll

阻塞io中用户进程阻塞和被唤醒的原理

在阻塞io中提到,当socket缓冲区中没有数据,用户线程会让出cpu,并进入阻塞状态,那么用户线程如何阻塞在socket,又是如何被内核唤醒的呢? 简单来讲就是socket会开辟一个等待队列,把用户进程存放在此,并注册回调函数(作用在于当数据到达socket之后唤醒用户线程)...具体函数和对应的数据结构就省略了...

socket创建过程

服务端调用accept之后,进入阻塞状态,客户端经过tcp三次握手之后,内核会创建一个socket作为服务端和客户端通信的内核接口,然后将这个socket放在当前进程打开的 文件列表中管理起来(/proc/{pid}/fd下,一般存在0:标准输入,1:标准输出,2:错误输出)

  1. epoll_create
  2. epoll_ctl
  3. epoll_wait

  epoll_create创建epoll对象,select用数组管理连接,poll用链表管理,epoll使用红黑树并且只返回活跃的socket连接,避免用户线程遍历全部连接查找活跃socket。红黑树在查找 、删除、插入综合性能是这几种数据结构中最优的。
  epoll_ctl可以向epoll对象中添加需要管理的socket连接。用户程序调用epoll_wait之后,内核会返回IO就绪的socket到回调事件,epoll回调函数是区别于select和poll采用轮询方式性能根本差异所在。

# 信号驱动IO

信号驱动io类比美食城取餐,老板会给个信号器,此时可以去找座位刷手机(做别的事),当信号器滴滴滴响的时候,我们就需要去取餐,由于取餐(拷贝数据的过程)还是自己来,所以这还是个同步IO模型。 但是在实际,使用TCP协议通信时,AIO几乎不会别采用,原因如下:

  1. 信号IO在大量IO操作时可能会因为IO信号队列溢出导致没办法通知
  2. 由于在unix系统中,SIGIO信号没有附加信息,产生TCP信号的事件不一定是数据到达缓冲区的时间,所以无法区分是否该通知用户程序读取数据。

# 异步IO(AIO)

以上四种模型都是同步IO模型,他们都会阻塞在数据拷贝阶段。异步IO在数据准备阶段和数据拷贝均有内核完成,应用程序在指定数组引用数据即可,不会造成任何阻塞。信号IO和异步IO在于内核通知可以拷贝数据和数据已经拷贝 完成的区别。Linux异步IO实现并不成熟,而信号IO机制不适合TCP协议,所以目前大部分还是采用多路复用模型


# 用户空间IO模型

用户空间的IO模型与内核空间区别仅仅在于分工不同,连接、读取、写入、计算...

accept
accept
用户态
用户态
内核态
内核态
IO读写
IO读写
业务处理
业务处理
业务处理
业务处理
业务处理
业务处理
分发
分发
网卡
网卡
Viewer does not support full SVG 1.1

Reactor模型有两个重要角色,Reactor负责IO建立连接监听,IO读写以及事件分发,handler负责处理分发的事件。Reactor依赖事件驱动。
reactor模型主要有三种

  1. 单线程

一个线程处理连接,IO读写以及事件分发和处理,缺点不言而喻

  1. 多线程

一个线程处理连接、IO读写事件分发,区别在于使用单线程或线程池另外处理分发的业务事件

  1. 主从模型

在多线程模型下进一步查分,主Reactor负责建立连接,并发连接时间注册到从Reactor,从Reactor负责监听读写和分发。监听和读写可以适当使用线程池提高性能。

# 网络模型

  • 应用层

http协议 请求头请求体

  • 传输层

tcp传输协议 三次握手四次挥手 内核完成

  • 网络层

ip协议 通过路由表 route -n找到destination地址的跳转路由地址,一般第一跳都是网关路由器

  • 链路层

四元组之外还要包含下一跳 那就arp -na 找到ip对应的网卡地址

  • 物理层