目录
- IO基础
- CPU与外设
- 1. 程序控制IO(轮询)
- 2. 中断
- 中断相关知识
- 中断分类
- 中断处理过程
- 中断隐指令
- 3. DMA(Direct Memory Access)
- 缓冲区
- 用户空间和内核空间
- IO操作的拷贝概念
- 传统IO操作的4次拷贝
- 减少一个CPU拷贝的mmap
- 内存映射文件(memory-mapped file,用户内存到文件系统页的映射)
- mmap的流程
- 1 进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域
- 2 调用内核空间的系统调用函数mmap(不同于用户空间的mmap函数),实现文件物理地址和进程虚拟地址的一一映射关系
- 3 进程发起对这块映射空间的访问,引发page fault,实现文件内容到物理内存(主存)的拷贝
- 附:Linux man mmap
- 零拷贝(无CPU拷贝) sendfile
- 附:用户态/内核态切换代价
- I/O模型:同步/阻塞概念
- 阻塞与非阻塞(等待I/O时的状态)
- 同步与异步(用户线程与内核的消息交互方式)
- 用水壶烧水的例子说明同/异步/阻塞/非阻塞
- 网络IO: Socket
- socket bind,listen,accept,connect等
- 文件IO
- 磁盘认识
- 磁盘的读写
- 磁盘随机/顺序访问
- 磁盘预读
- 局部性原理
- 页
- 再次看传统IO操作
- 网络IO模型演进
- BIO Socket服务端代码(阻塞,必须多线程)
- BIO的问题
- BIO多线程问题
- //TODO
不论是本地文件、数据库,还是Socket、远程网络,统统都可以认为是IO(Input/Output),总之是数据的流转存储。任何时候都要考虑数据从哪里来、经过怎样的处理、到哪里去,而这本身就是程序。
本文将从浅入深讲解IO入门必备,所有的概念过一遍也是大有裨益的。
IO基础
从一些基础的知识或常识入手,到逐步的深入理解程序的IO
CPU与外设
极慢
的外部设备如键盘、鼠标、打印机等如何让高速
的CPU控制操作
1. 程序控制IO(轮询)
CPU不断地查询
外围设备的工作状态,一旦外围设备“准备好”或“不忙”,即可进行数据的传送;主机与外设只能串行工作,主机一个时间段只能与一个外设进行通讯,CPU效率低。
CPU:轮询
,忙等待
2. 中断
每次IO操作都打扰下CPU,让CPU切换来处理IO
-
优点:CPU没有轮询检测I/O,只是根据I/O操作去向相应的设备控制器发出一条I/O命令,理论上可以去做其它的事情;
-
但是有大量数据传输时,CPU基本全程都在等待中断结束:在等待I/O数据的传输处理(CPU要等待中断返回,并没有去做别的事情)
一般类似下图所示
中断相关知识
外部设备完成工作,产生一个中断,它是通过在分配给它的一条总线信号线上置起信号而产生中断的。该信号主板上的中断控制器芯片检测到,由中断控制器芯片决定做什么。
在总线上置起中断信号,中断信号导致CPU停止当前正在做的工作并且开始做其它的事情。地址线上的数字被用做指向一个成为**中断向量(interrupt vector)**的表格的所用,以便读取一个新的程序计数器。这个程序计数器指向相应的中断服务过程的开始。
中断分类
- 中断源
中断是指由于某种事件的发生(硬件或者软件的),计算机暂停执行当前的程序,转而执行另一程序,以处理发生的事件,处理完毕后又返回原程序继续作业的过程。中断是处理器一种工作状态的描述。我们把引起中断的原因,或者能够发出中断请求信号的来源统称为中断源。
-
中断分类:外部中断/内部中断
- 外部设备请求中断。一般的外部设备(如键盘、打印机和A / D转换器等)在完成自身的操作后,向CPU发出中断请求,要求CPU为他服务。
- 故障强迫中断。计算机在一些关键部位都设有故障自动检测装置。如运算溢出、存储器读出出错、外部设备故障、电源掉电以及其他报警信号等,这些装置的报警信号都能使CPU中断,进行相应的中断处理。由计算机硬件异常或故障引起的中断,也称为内部异常中断。
- 实时时钟请求中断。在控制中遇到定时检测和控制,为此常采用一个外部时钟电路(可编程)控制其时间间隔。需要定时时,CPU发出命令使时钟电路开始工作,一旦到达规定时间,时钟电路发出中断请求,由CPU转去完成检测和控制工作。
- 数据通道中断。数据通道中断也称直接存储器存取(DMA)操作中断,如磁盘、磁带机或CRT等直接与存储器交换数据所要求的中断。
- 程序自愿中断。CPU执行了特殊指令(自陷指令)或由硬件电路引起的中断是程序自愿中断,是指当用户调试程序时,程序自愿中断检查中间结果或寻找错误所在而采用的检查手段,如断点中断和单步中断等。
-
中断分类:可屏蔽中断和非屏蔽中断
- 不可屏蔽中断源一旦提出请求,cpu必须无条件响应,而对于可屏蔽中断源的请求,cpu可以响应,也可以不响应。
- cup一般设置两根中断请求输入线:可屏蔽中断请求INTR(Interrupt Require)和不可屏蔽中断请求NMI(Nonmaskable Interrupt)。对于可屏蔽中断,除了受本身的屏蔽位的控制外,还都要受一个总的控制,即CPU标志寄存器中的中断允许标志位IF(Interrupt Flag)的控制,IF位为1,可以得到CPU的响应,否则,得不到响应。IF位可以有用户控制,指令STI或Turbo c的Enable()函数,将IF位置1(开中断),指令CLI或Turbo_c 的Disable()函数,将IF位清0(关中断)。
- 可屏蔽中断:CPU关中断,则CU不响应中断;中断屏蔽字,CPU响应优先级高的中断
中断处理过程
-
中断向量表用于保存:服务程序的入口地址
-
中断响应是在:一条执行执行之末;(缺页中断:是在一条指令执行中间,执行执行不下去了;在执行中,不得不去响应中断)
中断隐指令
中断隐指令引导CPU在响应中断信号时随机做出的一系列动作,这些动作是在检测到中断信号后便随即发生的,因而不能由软件来完成,而是由硬件来处理。中断隐指令并不是指令系统中的一条真正的指令,它没有操作码,所以中断隐指令是一种不允许、也不可能为用户使用的特殊指令。其所完成的操作主要有:
- 保存现场
为了保证在中断服务程序执行完毕能正确返回原来的程序,必须将原来程序的断点(即程序计数器(PC)的内容)保存起来。断点可以压入堆栈,也可以存入主存的特定单元中。
- 暂不允许中断(关中断)
暂不允许中断即关中断。在中断服务程序中,为了保护中断现场(即CPU主要寄存器的内容)期间不被新的中断所打断,必须要关中断,从而保证被中断的程序在中断服务程序执行完毕之后能接着正确地执行下去。并不是所有的计算机都在中断隐指令中由硬件自动地关中断,也有些计算机的这一操作是由软件(中断服务程序)来实现的。但是大部分计算机还是靠硬件来进行相关动作,因为硬件具有更好的可靠性和实时性。
- 引出中断服务程序
引出中断服务程序的实质就是取出中断服务程序的入口地址送程序计数器(PC)。对于向量中断和非向量中断,引出中断服务程序的方法是不相同的。
- 中断分发
硬件中断处理。在Windows所支持的硬件平台上,外部I/O中断进入到中断控制器的一根线上。该控制器接着在某一根线上中断处理器。处理器一旦被中断,就会询问控制器以获得此中断请求(IRQ)。中断控制器将该IRQ转译成一个中断号,利用该编号作为索引,在一个称为中断分发表(IDT)的结构中找到一个IDT项,并且将控制权传递给恰当的中断分发例程。每个处理器都有单独的IDT,所以,如果合适,不同的处理器可以运行不同的ISR。
3. DMA(Direct Memory Access)
DMA(Direct Memory Access)控制器是一种在系统内部转移数据的独特外设,可以将其视为一种能够通过一组专用总线将内部和外部存储器与每个具有DMA能力的外设连接起来的控制器。不需要依赖于CPU的大量中断,DMA控制器接管了数据读写请求,减少CPU的负担。
DMA向CPU申请权限,让DMA进行I/O操作;CPU不需要在负责大量的I/O操作而无法处理其它事情了,此处有DMA总线
缓冲区
类似于CPU三级缓存的概念(参考复习:https://doctording.blog.csdn.net/article/details/145303267),IO也需要有缓冲区。
因为计算机访问外部设备或文件,要比直接访问内存慢的多。如果我们每次调用read()
方法或者write()
方法访问外部的设备或文件,CPU就要花上最多的时间是在等外部设备响应,而不是数据处理。
为此,我们开辟一个内存缓冲区的内存区域,程序每次调用read()
方法或write()
方法都是读写在这个缓冲区中。当这个缓冲区被装满后,系统才将这个缓冲区的内容一次集中写到外部设备或读取进来给CPU。使用缓冲区可以有效的提高CPU的使用率,能提高整个计算机系统的效率。
对于用户缓存区,定义了如下几种类型
- 全缓冲
此种类型的缓冲只有在缓冲区满的时候才会调用实际的文件 IO 进入内核态操作。除了涉及到终端设备文件的流,其它文件流默认基本都是全缓冲。
- 行缓冲
此种类型的缓冲在缓冲区满或者遇到 \n
的时候才会调用实际的文件 IO 进入内核态操作。当流涉及到终端设备的时候就是行缓冲,比如标准输入流和标准输出流。如果对标准输入流或者输出流进行重定向到某个文件的时候,该流就是全缓冲的。
- 无缓冲
没有缓冲区。直接调用文件 IO 进入内核态操作。标准错误流默认就是无缓冲的。
用户空间和内核空间
用户空间通常是常规进程所在区域,即非特权区域,不能直接访问磁盘硬件设备;通常要通过操作系统系统调用或者驱动程序完成。
用户空间不能直接访问磁盘硬件设备,如下的一些原因这也很容易理解:
-
权限限制:在操作系统中,用户空间和内核空间有不同的权限设置。用户空间运行的应用程序通常只能访问用户空间资源,而无法直接访问内核空间或硬件设备。这是为了系统的稳定性和安全性,防止普通应用程序直接操作硬件可能导致系统崩溃或数据损坏。
-
设备驱动:硬件设备需要通过设备驱动程序进行管理和操作。设备驱动程序运行在内核空间,负责将硬件设备的操作转换为操作系统可以理解的形式。用户空间的应用程序通过调用内核提供的接口来间接操作硬件设备。
-
抽象层:操作系统通过设备驱动程序和文件系统等抽象层将硬件设备映射为文件或设备文件,用户可以通过标准的文件操作函数来访问这些设备。这种方式简化了硬件操作,同时也提供了更好的兼容性和灵活性。
所以这也就引入了内核空间
- 用户空间的程序不能直接去磁盘空间中读取数据,就由内核空间通过DMA来获取
- 另外也注意到一般用户空间的内存分页与磁盘空间不会对齐,而内核空间可在中间做一层处理
IO操作的拷贝概念
有了缓存和用户/内核空间的基础,接下来来看不同的IO操作,也是理解常说的拷贝概念
传统IO操作的4次拷贝
回顾传统IO操作流程:
即
-
写:
用户态->内核态->DMA
: 进行了CPU copy, DMA copy -
读:
DMA->内核态->用户态
: 进行了DMA copy, CPU copy
减少一个CPU拷贝的mmap
mmap即内存映射,mmap()是由unix/linux操作系统来调用的,它可以将内核缓存中的一块区域与用户缓存中的一块区域形成映射关系,即共享内存,不过在用户缓存中的这块映射区域是堆外内存;其中,文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。
常见为如下:
读
- 用户进程要读一个磁盘文件,告诉内核进程发起mmap()函数调用,内核将一块内核缓存和用户缓存中的一块堆外内存建立的映射关系,并告诉DMA将这个文件中的数据拷贝到了这块内核缓存中
- 用户开始IO,因为磁盘文件已经被DMA拷贝到内核缓存中去了,又被映射到了这块堆外内存,所以就直接在用户缓存里就读到数据了,线程没有上下文切换
写
- 用户线程发起了write()调用,状态由用户态切换为内核态,这时候内核基于CPU拷贝将数据从那块映射着的内核缓存拷贝到socket缓存(这是在内核空间的拷贝)
- 然后是DMA将数据从socket缓存拷贝到网卡,最后write()函数调用返回,线程从内核态切换到用户态
所以mmap可总结为
- DMA拷贝2次
- CPU拷贝1次(内核空间的内核缓冲区到socket缓冲区)
内存映射文件(memory-mapped file,用户内存到文件系统页的映射)
由一个文件到一块内存的映射;文件的数据就是这块区域内存中对应的数据,读写文件中的数据,即直接对这块内存区域的地址操作,减少了内存复制的环节。
使用内存映射文件处理存储于磁盘上的文件时,将不必再对文件执行I/O操作,这意味着在对文件进行处理时将不必再为文件申请并分配缓存,所有的文件缓存操作均由系统直接管理,由于取消了将文件数据加载到内存、数据从内存到文件的回写以及释放内存块等步骤,使得内存映射文件在处理大数据量的文件时能起到相当重要的作用。
好处:
-
用户进程把文件数据当作内存,所以无需发起
read()
或write()
系统调用。 -
当用户进程碰触到映射内存空间,页错误会自动产生,从而将文件数据从磁盘读进内存。如果用户修改了映射内存空间,相关页会自动标记为脏,随后刷新到磁盘,文件得到更新。
-
操作系统的虚拟内存子系统会对页进行智能高速缓存,自动根据系统负载进行内存管理。
-
数据总是按页对齐的,无需执行缓冲区拷贝。
-
大型文件使用映射,无需耗费大量内存,即可进行数据拷贝。
-
映射文件区域的能力取决于于内存寻址的大小。在32位机器中,你不能访问超过
4GB
(2 ^ 32
)以上的文件。
mmap的流程
1 进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域
进程在用户空间调用mmap库函数
- 在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址
- 为此虚拟区分配一个vm_area_struct结构,接着对这个结构的各个域进行了初始化
- 将新建的虚拟区结构(vm_area_struct)插入进程的虚拟地址区域链表或树中
2 调用内核空间的系统调用函数mmap(不同于用户空间的mmap函数),实现文件物理地址和进程虚拟地址的一一映射关系
为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核已打开文件集
中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息。
-
通过该文件的文件结构体,链接到file_operations模块,调用内核函数mmap,其原型为:int mmap(struct file *filp, struct vm_area_struct *vma),不同于用户空间库函数。
-
内核mmap函数通过虚拟文件系统inode模块定位到文件磁盘物理地址。
-
通过remap_pfn_range函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。
注:仅创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。
3 进程发起对这块映射空间的访问,引发page fault,实现文件内容到物理内存(主存)的拷贝
- 进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常。
- 缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程。
- 调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用nopage函数把所缺的页从磁盘装入到主存中。
- 之后进程即可对这片内存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程
注意:脏页不能被置换出内存,如果脏页正在被写回,那么会被设置写回标记,这时候该页就被上锁,其他写请求被阻塞直到锁释放。
附:Linux man mmap
man mmap
- 使用语法
NAME
mmap, munmap - map or unmap files or devices into memory
SYNOPSIS
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
int munmap(void *addr, size_t length);
零拷贝(无CPU拷贝) sendfile
注意零拷贝是无CPU拷贝,DMA拷贝还是必须的。
sendfile
系统调用利用DMA引擎将文件内容拷贝到内核缓冲区去,同时将带有文件位置和长度信息的缓冲区描述符添加socket缓冲区去,这一步不会将内核中的数据拷贝到socket缓冲区中(即没有CPU拷贝),DMA引擎随后会将内核缓冲区的数据拷贝到协议引擎中去,避免了最后一次CPU拷贝
附:用户态/内核态切换代价
NAME
sendfile - transfer data between file descriptors
SYNOPSIS
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
DESCRIPTION
sendfile() copies data between one file descriptor and another. Because this copying is done within
the kernel, sendfile() is more efficient than the combination of read(2) and write(2), which would
require transferring data to and from user space.
I/O模型:同步/阻塞概念
上午谈到了很多底层的IO概念,但是回到面向程序员的IO编程世界,我们首先必须要懂的概念是:阻塞与非阻塞,同步异步。因为这直接决定了程序员要怎么编写代码
阻塞与非阻塞(等待I/O时的状态)
函数或方法(用户线程调用内核I/O操作)的实现方式:
- 阻塞是指I/O操作需要彻底完成后才返回到用户空间
- 非阻塞是指I/O操作被调用后立即返回给用户一个状态值,无需等到I/O操作彻底完成。
同步与异步(用户线程与内核的消息交互方式)
- 同步指用户线程发起I/O请求后需要等待或者轮询内核I/O操作完成后才能继续执行;同步有阻塞,非阻塞之分
- 异步是指用户线程发起I/O请求后仍然可以继续执行,当内核I/O操作完成后会通知用户线程,或者调用用户线程注册的回调函数。异步一定是非阻塞的(内核会通过函数回调或者信号机制通知用户进程;类似观察者模式)
用水壶烧水的例子说明同/异步/阻塞/非阻塞
- 同步阻塞
- 点火(发消息)
- 搬个小板凳盯着水壶(傻等,眼睛不动),不等到水壶烧开水,坚决不去做别的事情(阻塞)
用户线程的IO处理过程需要等待,中间不能做任何事情,对CPU利用率很低
- 同步非阻塞
- 点火(发消息)
- 去看会儿电视,时不时过来(轮询)看水壶烧开水没有(非阻塞);水开后接着处理
用户线程每次IO请求都能立刻返回,但需要通过轮询去判断数据是否返回,会无谓地消耗大量的CPU
- 异步阻塞(很少发生)
- 点火(发消息)
- 水壶有个响铃,自动绑定了开水之后的处理程序,这样响铃之后自动处理(异步)
- 但是还是可以轮询去看水壶开了没有
- 异步非阻塞
- 点火(发消息), 写好水壶烧开水之后的处理程序
- 水壶有个响铃,自动绑定了开水之后的处理程序,这样响铃之后自动处理
- 人该干嘛干嘛去,不用管了(不用傻等,不用轮询)
网络IO: Socket
Socket是什么?
Socket是对TCP/IP协议的封装,它的出现只是使得程序员更方便地使用TCP/IP协议栈而已。socket本身并不是协议,它是应用层与TCP/IP协议族通信的中间软件抽象层,是一组调用接口。
使用5元组(客户端ip:port,服务端ip:port,协议) 或者 文件描述符 fd,唯一的表示
附:传输层和网络层的明显区别是:网络层为主机之间提供逻辑通信,而传输层提供端到端的逻辑通信
socket bind,listen,accept,connect等
比如sock_listen系统调用
- 将未链接的套接口转换为被动套接口,指示内核接受向此套接口的连接请求,调用此系统调用后tcp状态机由close转换到listen。
- 第二个参数指定了内核为此套接口排队的最大连接个数。关于第二个参数,对于给定的监听套接口,内核要维护两个队列,未连接队列和已连接队列,根据tcp 三路握手过程中三个分节来分隔这两个队列。已完成连接的队列和未完成连接的队列之和不超过backlog。
static int sock_listen(int fd, int backlog)
{
struct socket *sock;
if (fd < 0 || fd >= NR_OPEN || current->files->fd[fd] == NULL)
return(-EBADF);
if (!(sock = sockfd_lookup(fd, NULL)))
return(-ENOTSOCK);
if (sock->state != SS_UNCONNECTED)
{
return(-EINVAL);
}
if (sock->ops && sock->ops->listen)
sock->ops->listen(sock, backlog);
// 设置socket的监听属性,accept函数时用到
sock->flags |= SO_ACCEPTCON;
return(0);
}
文件IO
文件系统是安排、解释磁盘数据的一种独特方式,文件系统定义了文件名、路径、文件、文件属性等一系列抽象概念。
当用户进程请求文件数据时,文件系统需要确定数据在磁盘什么位置,然后将相关磁盘分区读取到内存中。
磁盘认识
- 盘面:盘面类似于光盘的数据存储面,由许多同心圆的磁道组成的盘面。一块硬盘有多个盘面
- 柱面:垂直方向由多个盘面组成,读取或者写入数据都是垂直方向的从第一个盘面同一磁道一直写入到最后一个盘面,然后数据还没写完的话在切换磁道
- 磁道:盘片被划分成一系列同心环,圆心是盘片中心,每个同心环叫做一个磁道;同一磁道再被划分成多个扇区
- 扇区:存储数据,每个扇区包括512个字节的数据和一些其他信息(每个扇区是磁盘的最小存储单元)
磁盘的读写
一次访盘请求(读/写)完成过程由三个动作组成:
- 寻道(时间):磁头移动定位到指定磁道
- 旋转延迟(时间):等待指定扇区从磁头下旋转经过
- 数据传输(时间):数据在磁盘与内存之间的实际传输
因此在磁盘上读取扇区数据(一块数据)所需时间:
Ti/o=tseek + tla + n * twm
- tseek 为寻道时间
- tla为旋转时间
- twm 为传输时间
磁盘随机/顺序访问
- 随机访问(Random Access)
指的是本次I/O所给出的扇区地址和上次I/O给出扇区地址相差比较大;这样的话,磁头在两次I/O操作之间需要作比较大的移动动作才能重新开始读/写数据
- 顺序访问(Sequential Access)
如果当次I/O给出的扇区地址与上次I/O结束的扇区地址一致或者是接近的话,那磁头就能很快的开始这次IO操作,这样的多个IO操作称为顺序访问
磁盘预读
磁盘读取的一系列动作,导致其读写很慢;要提高效率,显然要尽量减少磁盘IO,为了达到这个目的,磁盘往往不是严格按需读取,而是每次都会磁盘预读,即使只需要一个字节,磁盘也会从这个位置开始,顺序向后读取一定长度的数据放入内存,这通常是一页的整倍数
局部性原理
当一个数据被用到时,其附近的数据也通常会马上被使用。程序运行期间所需要的数据通常比较集中。由于磁盘顺序读取的效率很高(不需要寻道时间,只需很少的旋转时间) ,因此对于具有局部性的程序来说,磁盘预读可以提高1/0效率。预读的长度一般为页(page)的整倍数。页是计算机管理存储器的逻辑块,硬件及操作系统往往将主存和磁盘存储区分割为连续的大小相等的块,每个存储块称为一页(在许多操作系统中,页得大小通常为4k) ,主存和磁盘以页为单位交换数据。当程序要读取的数据不在主存中时,会触发一个缺页异常,此时系统会向磁盘发出读盘信号,磁盘会找到数据的起始位置并向后连续读取一页或几页载入内存中,然后异常返回,程序继续运行。
页
页是计算机管理存储器的逻辑块,硬件及操作系统往往将主存和磁盘存储区分割为连续的大小相等的块,每个存储块称为一页(在许多操作系统中,页得大小通常为4k),主存和磁盘以页为单位交换数据。当程序要读取的数据不在主存中时,会触发一个缺页异常,此时系统会向磁盘发出读盘信号,磁盘会找到数据的起始位置并向后连续读取一页或几页载入内存中,然后正常返回,程序继续运行
再次看传统IO操作
网络IO模型演进
BIO Socket服务端代码(阻塞,必须多线程)
public class BIOServer {
public static void main(String[] args) throws IOException {
ServerSocket ss = new ServerSocket();
ss.bind(new InetSocketAddress("127.0.0.1", 8888));
while(true) {
Socket s = ss.accept(); //阻塞方法
// 每个客户端socket都开一个线程处理
new Thread(() -> {
handle(s);
}).start();
}
}
static void handle(Socket s) {
try {
byte[] bytes = new byte[1024];
int len = s.getInputStream().read(bytes); // 阻塞方法
System.out.println("read data:" + new String(bytes, 0, len));
s.getOutputStream().write(bytes, 0, len);
s.getOutputStream().flush();
} catch (IOException e) {
e.printStackTrace();
}
}
}
BIO的问题
- 阻塞IO,必须使用多线程来处理多个客户端连接
- 操作系统clone系统调用创建线程,线程的创建开销比较大
- 线程是消耗资源的,如JVM默认1M的线程栈内存
- 线程很多的时候,CPU调度,上下文切换频繁
- 阻塞IO,会有CPU时间片的浪费
BIO多线程问题
一个线程负责多个文件的读写,如果按照阻塞的方式,那么必须是按照文件1, 文件2, 文件3… 顺序的读取这么多文件; 如果是非阻塞方式, 你可以同时发起成百上千个读操作,然后在那个循环中检查,看看谁的数据准备好了,就读取谁的,效率就高了。
socket编程:一个socket连接来了,就创建一个新的线程或者从线程池分配一个线程去处理这个连接,显然线程数不能太多,线程的切换也是个开销;所以让一个线程管理成百上千个sockcet连接,就像管理多个文件一样,这样就不用做线程切换了。
-
使用多线程的本质
- 充分利用多核(线程是CPU调度的基本单位;进程是系统进行资源分配的基本单位)
- 当I/O阻塞系统,但CPU空闲的时候,可以利用多线程使用CPU资源。(现在的多线程一般都使用线程池,这可以让线程的创建和回收成本相对较低。在活动连接数不是特别高(小于单机
1000
)的情况下,这种模型是比较不错的,每一个连接线程可以专注于自己的I/O并且编程模型简单,也不用过多考虑系统的过载、限流等问题)
-
多线程缺点,总结如下
- 线程的创建和销毁成本很高,在Linux这样的操作系统中,线程本质上就是一个轻量级进程,创建和销毁都是重量级的系统函数调用
- 线程本身占用较大内存,比如Java的线程栈一般至少分配
512K~1M
的空间,如果系统中的线程数过千,恐怕整个JVM的内存都会被吃掉一半 - 线程的切换成本是很高的,操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统调用。如果线程数过高,可能执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现往往是系统
load偏高
、CPU使用率特别高
(超过20%以上),导致系统几乎陷入不可用的状态 - 容易造成
锯齿状的系统负载
。因为系统负载是用活动线程数或CPU核心数,一旦线程数量高且外部网络环境不是很稳定,就很容易造成大量请求的结果同时返回,并激活大量阻塞线程从而使系统负载压力突然过大 - 多线程的引入,可能会引入线程安全问题,系统的设计会比较复杂
结论:当面对十万甚至百万级连接的时候,传统的BIO模型是无能为力的。