0.中断
0.1 简介
中断是指计算机运行过程中,出现某些意外情况需主机干预时,机器能自动停止正在运行的程序并转入处理新情况的程序,处理完毕后又返回原被暂停的程序继续运行
-------- 引用百度百科
例如, 你正在家看电影,突然外卖送到了,此时你不得不暂停电影,停止播放,去拿外卖
这个过程就是中断,在计算中中断分为两种:
- 硬中断
- 软中断
0.2 硬中断
硬中断通常是由硬件发起的中断,电脑外设,网卡等发起的中断,例如最常见的键盘打字场景如下:
CPU
正在运行用户程序,那么当用户在键盘输入字符时,那么键盘就会发起中断,CPU
的中断引脚(INTR
)就会接受到中断信号(唯一的数字标识),从而调用内核程序将输入的字符展示在对应的地方。
当然CPU不可能给每个硬件设备都弄一个中断引脚,因此中断信号(唯一的数字标识)时通过中断控制器去发送到CPU
的中断引脚,如下图所示:
8259A
是一个中断控制器,每个接口都连接不同的硬件设备,当硬件设备超过8个时可以再连接一个中断控制器
中断控制器大概工作流程就是 中断请求寄存器保存多个硬件的中断请求信息,接下来通过优先级解析器对 中断请求进行排序,数字越小越先执行,最后将正在执行的中断请求存入
正在服务寄存器中,如下图:
当然每个中断信号其实本质就是一个位置的数字,那么每个数字即要执行什么样的程序,这个由中断向量表,即 把系统中所有的中断类型码及其对应的中断向量按一定的规律存放在一个区域内,这个存储区域就叫中断向量表
在内核程序启动时就会去加载这个表,这样就知道了,不同的硬件设备中断就会执行不同的程序
0.3 软中断
众所周知,系统运行速度越快越好,最好是实时系统,因此,Liunx
为了满足这点,当中断发生时,将耗时较短的操作的操作交给硬中断处理,而一些耗时较长的的工作交给软中断处理。
例如,网卡接收到数据时,就会发送一个硬中断请求,CPU
接收到了这个中断,就会快速将网卡中的数据存放到内存中,然后发送一个软中断,当软中断信号被唤醒后,CPU
就会处理内存中的数据,在处理期间还是能够响应其他的硬中断,如下图:
1.FD
在Linux
中,一切皆文件,内核则是利用文件描述符来操作文件,当新建了一个文件或者打开现存文件都会返回一个文件描述符fd
(整型数字),然后通过文件描述符来进行操作。
例如可以使用exec
文件来给文件创建fd
,例如:
touch test.txt
exec 5<test.txt
exec 6>test.txt
数字5 就代表 test.txt
这个文件的读操作,操作fd5
,即操作这个文件都操作
数字6 就代表 test.txt
这个文件的写操作,操作fd5
,即操作这个文件都操作
#代表将hello写入到test.txt文件中
echo 'hello' >& 6
当然在linux
中,任何程序都具备三个基础文件描述符0
,1
,2
,含义如下:
-
0
代表标准输入 -
1
代表标准输出 -
2
代表错误输出
可以以下命令查询当前进程的文件描述符
# $$ 代表当前进程
ls /proc/$$/fd
2. 状态
在操作系统中CPU有两种状态,分别是
-
内核态
运行内核程序的状态称为内核态
-
用户态
运行用户程序成为用户态
这种状态会时常进行切换,例如当发生中断时,CPU就会从用户态切换到内核态
3. Server
每个服务端程序一旦运行都会去创建socket
,当然socket
也是文件描述符进行表示的,
socket
一旦创建,就会进入bind
阶段,将地址绑定到socket
上,绑定完成后就会开始进行监听,监听完成并开始进行调用accpet
方法
以下对上述步骤进行详细演示
3.1 socket
这里使用
nc
命令进行创建服务端程序,如果没有可以安装
# 创建服务端程序 并且端口号为8989
nc -l 8989
一旦执行此命令,CPU就会调用内核去执行内核的socket && bind && listen
方法
当然也可以先找到nc
的进程id,然后查看该进程的所有文件描述符
可以另起一个tab页,之前的程序不要关闭
ps -ef | grep nc
例如我这里是6040,那么我就需要去6040下去找对应的文件描述符,如下:
ll /proc/6040/fd
可以发现多了两个文件描述符3
,4
,而且分别指向了socket
为了更加了解nc -l 8989
运行以后,内核程序是如何执行的,可以通过strace
命令查看到应用程序与内核的交互过程,如下:
先停掉之前的程序
# strace 追踪与内核交互程序
# -ff 如果一个程序启动有多个线程,那么就会按照线程号记录到每个文件中
# -o kk 将内容输出到kk文件中
strace -ff -o kk nc -l 8989
另起一个tab
页,就可以看到输出的kk
文件,如下图所示:
打开文件,查看器文件内容,可知,当执行完指令后创建了两个socket
,如下图:
之所以有两个
socket
,是因为一个是IPV4
,一个是IPV6
同时当socket方法执行完成以后,返回了文件描述符3,4
为了更加了解socket
方法可以通过man
指令查看socket
的描述,如下:
# 2 代表查看2类命令
man 2 socket
从描述可以出,socket
方法调用代表创建一个通信端点,并且返回一个fd
调用时需给这个方法传入一个 域名参数,类型参数,和协议参数
因此只要是服务端程序一旦启动,就一定会调用socket
调用内核程序,也叫系统调用
3.2 bind
继续查看kk
文件内容可知,当socket
创建完成以后,紧接着会调用bind
内核程序,如下图所示:
从图中代码可以知道,是将端口和地址绑定到了fd4
这个socket
上,为了更加了解清楚
可以继续查看手册,了解其细节 ,如下:
从上图中可知,当socket
创建成功后,并没有分配给scoket
地址,因此借助bind
需要给socket
分配地址。
当分配成功后就会返回一个0,注意这个0并不是fd
3.3 listen
继续查看kk
文件,发现当bind
过后紧接着调用了listen
程序,如下图:
同理查看手册去了解该方法的作用
从上图可知,该方法是用来监听是否有客户端来连接这个socket
,如果一旦连接就会回调accept
方法
至此可以发现,任何一个服务端程序启动都会经过
socket bind listen
三个步骤
4.client
4.1 accpet
当使用nc
程序连接服务端时,从上面描述可知会先调用accpet
方法,与服务端建立连接
nc localhost 8989
继续查看kk
文件,可以发现调用了accpet
方法建立了连接,如下:
注意: 此时并没有给服务端发消息,只是建立三次握手
同理查看手册去了解该方法的说明
从图中可知,该它提取挂起连接队列上的第一个连接请求侦听套接字的连接sockfd
创建一个新的已连接套接字,并返回引用该套接字的新文件描述符
意思就是创建一个客户端连接的socket,然后返回给socket的文件描述符,从之前的图可知,目前的socket
描述为7
注意:这里说的
socket
指的是客户端连接的socket
4.2 recvfrom
当客户端给服务端发送消息,服务端就会调用recvfrom
程序接受网卡中的消息,并且通过调用write
方法去写入到用户程序上,如下:
因此整个流程客户端与服务端通信过程是:
-
服务端启动后先系统调用
socket
,bind
,listen
多路复用器
select
后续再说 -
客户端与服务端建立三次握手时,先将数据包发送到网卡,网卡发起硬中断,然后由
用户态切换到内核态,调用
accpet
方法建立连接创建客户端socket
客户端发送消息时,还是会发送到网卡,然后发起中断,状态切换,最后接收消息,然后通过
write
调用将消息写回用户程序
5.BIO
BIO
(阻塞式IO),即socket返回的文件描述符都是阻塞的,如果说连接的数据没有到达那么就会一直阻塞在那,等到数据到达。这种效率可想而知是非常低的
5.1 案例
该案例简单模拟一下BIO
在系统中安装java
环境,并且准备一个java
程序,内容如下:
import java.io.*;
import java.net.*;
public class Server {
public static void main(String[] args) throws IOException {
ServerSocket ss = new ServerSocket(8888);
System.out.println("create server socket");
while(true) {
Socket socket = ss.accept();
System.out.println("client port:" + socket.getPort());
BufferedReader br = new BufferedReader(new InputStreamReader(socket.getInputStream()));
while(true) {
System.out.println(br.readLine());
if(br.readLine().equals("exit")) break;
}
}
}
}
从上图程序可知,当接收到一个socket后,会开启一个
死循环
读取这个socket发送过来的消息如果消息没有发送过来就会一直等待,这样即使有第二个socket发送消息过来也只能是等待
输入以下指令执行程序
strace -ff -o bbb java Server
发现程序一直阻塞着等待客户端的连接,如下图:
此时可以再起一个tab页,查看用户内核交互记录
发现产生了很多文件,这是因为jvm
启动时会创建一些线程去处理一些事情,例如垃圾回收等等之类
输入jps
指令,可以发现Server
程序运行在2039
进程id上,如下图所示:
通过以下命令,进入进程对应的目录中
cd /proc/2039/fd
查看内容详情,如下图:
-
3
是因为程序启动时会加载rt.jar
,所以会有一个文件描述符 3 -
4
,5
代表创建的socket
,之所以有两个是因为一个是IPV4 Socket
,一个是IPV6 Socket
nc
命令可以与任何服务端程序建立连接
通过以下命令与服务端程序(Server
)建立通信,如下:
# 注意:在两个tab页同时输入该命令
nc localhost 8888
可以从输出的消息可以看出,只输出了一个客户端的消息,而第二个客户端的消息并没有输出,如下:
这种通信模型即BIO
模型,也就是说其socket
是阻塞的,一直等待客户端消息发送过来
如果不发送一直阻塞,其他客户端也无法连接过来
5.2 thread
从上图也可知,当在高并发的情况下并不是适合,不可能10w
个客户端一直要等服务端挨个处理完成
为了提高程序的并发能力,采用一些人采取了多线程的方式,如下:
import java.io.*;
import java.net.*;
public class Server {
public static void main(String[] args) throws IOException {
ServerSocket ss = new ServerSocket(8888);
System.out.println("create server socket");
while(true) {
Socket socket = ss.accept();
new Thread(() -> {
try {
System.out.println("client port:" +
socket.getPort());
BufferedReader br =
new BufferedReader(
new InputStreamReader(
socket.getInputStream()));
while(true) {
System.out.println(br.readLine());
}
} catch(Exception e) {
e.printStackTrace();
}
}).start();
}
}
}
输入以下指令运行程序
# 最好是将之前的bbb文件删除吗,防止引起混乱
strace -ff -o mm java Server
运行成功以后再通过nc
程序去连接服务端,这里还是启动两个客户端去连接服务端程序
nc localhost 8888
从服务端输出的结果可以看出完美解决了上述问题,如下图
5.3 缺点
上述多线程的方式,解决了阻塞问题,但是从本质来讲socket
还是阻塞的,只不过是换成了多线程的方式
查看与内核交互的日志,去了解当创建线程时,内核干了什么事情
从上图可知,文件有多个,我们并不知道主线程对应的是哪个文件,因此可以筛选以下,因为主线程启动时会输出create server socket
,这样可以通过以下命令进行筛选
grep 'create server socket' ./mm.*
从图中可以看出,主线程相关内容是在6341
文件上,因此查看该文件内容
vim mm.6341
从文件内容上可以看出,创建线程其本质是调用内核的clone
方法,如下图所示:
且与线程的文件一一对应,如下图:
在此我们得出结论,每创建一个线程就会发生一个系统调用,假设10W
个客户端连接进来,那么就意味着要创建10W
个线程,发生10W
次系统调用
且不说能不能能不能支持这么多线程数,即使能支持,CPU的线程调度,系统调用也会大大消耗资源
因此如果这种方式并不适合在并发的情况下使用
3.NIO
基于上述原因,因在在Linux中提供了另外一个socket
,即非阻塞式socket
,主要是为了解决BIO
问题,如下:
有了非阻塞Socket
,那么就意味着不用向之前那样进行等待阻塞又或者是创建多个线程去处理客户端,即一个线程也可以去处理多个客户端程序,而不阻塞
此时工作方式就变成了以下方式:
socket(...) = 4
bind(4,...) = 0
listen(4)
accpet(4,...) = -1 # 返回的socket不再阻塞等待客户端连接,没有数据到达返回 -1
# 当有下一个客户端连接过来会继续 accpet
accpet(4,...) = 5
listen(5)
recvfrom(5) = -1 # 没有数据到达返回 -1
因此修改之前的程序代码,如下:
public static void main(String[] args) throws IOException {
List<SocketChannel> socketChannels = new ArrayList<>(10);
ServerSocketChannel ss = ServerSocketChannel.open();
ss.bind(new InetSocketAddress(8888));
while (true) {
// 不会阻塞 如果客户端没有发送数据那么channel为空
SocketChannel channel = ss.accept();
if (channel == null) {
System.out.println("发数据为空");
} else {
ss.configureBlocking(false);
socketChannels.add(channel);
}
socketChannels.forEach(t -> {
// 读取客户端消息
});
}
}
从上述伪代码可知,这种工作模型一个线程可以接收多个客户端请求,然后在程序中即用户态中判断客户端发送的消息是否到达。到达则处理数据,不到搭达则进行下一次循环
每次查询是否到达都需要调用
recv...
方法,也就是系统调用,而用户态与内核态的切换时比较小号资源的当有
10W
个客户端,意味着每次循环都要经历10W
次,那么这个效率肯定时非常地下的
4. 多路复用
4.1 select
为了解决上述在用户态循环多次系统调用问题,内核
增加了一个系统调用select
,通过命令man 2 select
查看其介绍可知:
从上图中可以看出,select
函数,可以一次监听多个文件描述符,一旦调用就会进入阻塞状态,当客户端的消息到达时,就会返回对应客户端的fd
也就是说以前需要在程序中循环判断消息是否到达,而现在只需要将多个socket
的fd
传给select
,在内核内部就会判断,如果到达就会返回fd
,用户态就可以再次操作,如下:
这种相当于以前是在用户态即用户程序员那边去判断,现在只需要调用一次select
即可判断,这样系统调用次数也就降低了
这样由n
次系统调用降低为了1
,调用一次select
就知道哪些文件描述符时可用的,之前的poll
函数就是类似功能
同时从参数select
方法参数可知,如下图
-
nfds
待监听的最大fd
指+1,最大值为1024 -
*readfds
待监听的可读fd集合 -
*writefds
待监听可写fd集合 -
exceptfds
带监听异常fd集合 -
timeout
超时时间
select虽然降低了NIO或者BIO过程中的多次系统调用,但是有以下缺点无法解决:
- select是直接在
readfds
和writefds
操作,导致这两个数组不可重用,每次都需要重新赋值- 每次select都要便利全量的fds
5.EPOLL
epoll
是Linux所特有的
epoll
本质是一个组合系统掉i用,由三部分组成
- epoll_create
- epoll_ctl
- epoll_waite
当然也可以从其手册上看出,如下图所示:
5.1 epoll_create
当服务端程序,调用epoll_create
时,就会创建一个epoll实例,在内核中开辟一块空间,并且返回一个文件描述符,这个文件描述符就是用来描述这块开辟的空间,当然也可以从其手册中可以看出来,如下:
从图中的描述可以看出,当调用epoll_create
方法时,需要传入一个size
,但是这个size
从2.6.8
版本后已经忽略掉了
开辟的内核空间,其本质就是一个结构体,这个结构体主要是是由红黑树
+ 就绪链表
组成
5.2 epoll_ctl
从手册中可以看出,当调用该方法需要传入之前epoll_create
返回的文件描述符,如下图:
关于参数解释如下:
epfd
epoll
实例的fd-
op
对空间红黑数进行操作,操作参数如下图-
epoll_ctl_add
表示将fd
添加到之前在内核开辟的内存空间,也就是将fd
添加到红黑树上 - 同时
mod
和del
,则表示修改和删除
-
fd
socket
的fd
5.3 epoll_wait
当调用epoll_wait
方法时,就会直接获取到达消息文件描述符,应用程序进行读写操作
整个工作流程如下: