1. 总体概念
1.1 操作系统的特性
四个特性:并发、共享、虚拟、异步。
- 并发:同一段时间内(时间片轮转算法)多个程序执行。程序并发性体现在两个方面: 用户程序与用户程序之间的并发执行。 用户程序与操作系统程序之间的并发。
- 共享:系统中的资源可以被内存中多个并发执行的进线程共同使用。
- 虚拟:通过时分复用(虚拟处理机、虚拟设备)以及空分复用(如虚拟内存,虚拟磁盘)技术实现把一个物理实体虚拟为多个。
- 异步:系统中的进程是以走走停停的方式执行的,且以一种不可预知的速度推进。(同步就是实时处理,比如打电话,异步就是分时处理,比如发短信)
1.2 操作系统的主要功能
操作系统的本质是对资源的管理。包括了:
- 处理器管理:以进程为单位分配资源,
- 存储器管理:也叫内存管理
- 设备管理:完成所有的IO请求
- 文件管理:包括磁盘存储空间管理,文件读写管理等等
1.3 用户态和内核态
从整体上讲,操作系统一般可分为**内核(kernel)和外壳(shell)**两大部分。

内核态与用户态是操作系统的两种运行级别,
用户态:当进程在执行用户自己的代码时,则称其处于用户态,这时cpu访问资源有限,运行在用户态下的程序不能直接访问操作系统内核数据结构和程序。
内核态:当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核状态,这时cpu可以访问计算机的任何资源。
当程序运行在0级特权级上时,就可以称之为运行在内核态,CPU将指令分为特权指令和非特权指令,
对于那些危险的指令,只允许操作系统及其相关模块使用,普通的应用程序只能使用那些不会造成灾难的指令。比如清内存、设置时钟。运行在用户态下的程序不能直接访问操作系统内核数据结构和程序。
当程序运行在3级特权级上时,就可以称之为运行在用户态,因为这是最低特权级,是普通的用户进程运行的特权级,大部分用户直接面对的程序都是运行在用户态;
两种状态的主要区别
处于用户态执行时,进程所能访问的内存空间和对象受到限制,其所处于占有的处理机是可被抢占的 ;
而处于内核态执行中的进程,则能访问所有的内存空间和对象,且所占有的处理机是不允许被抢占的。
用户态切换到内核态有三种情况:主动,被动,被迫
- 系统调用:用户态进程主动要求切换到内核态申请使用操作系统提供的服务程序完成工作的一种方式,fork()实际上就是执行了一个创建新进程的系统调用。(主动)
- 异常:当前运行进程切换到处理此异常的内核相关程序 (被迫)
- 外围设备中断:当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序。 如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。(被动)
从触发方式上看,可以认为存在前述3种不同的类型。
但是从最终实际完成由用户态到内核态的切换操作上来说,涉及的关键步骤是完全一致的,没有任何区别。
都相当于执行了一个中断响应的过程,因为系统调用实际上最终是中断机制实现的,而异常和中断的处理机制基本上也是一致的,关于它们的具体区别这里不再赘述。关于中断处理机制的细节和步骤这里也不做过多分析。
涉及到由用户态切换到内核态的步骤: 需要注意的是,内核态堆栈仅用于内核例程,Linux内核另外为中断提供了单独的硬中断栈和软中断栈 [1] 从当前进程的描述符中提取其内核栈的ss0及esp0信息。(ss0段选择子,用于指示内核堆栈所在的段描述符,esp堆栈是一个32位寄存器,存储了内核模式下的堆栈顶部地址指针) [2] 使用ss0和esp0指向的内核栈将当前进程的cs,eip,eflags,ss,esp信息保存起来,这个过程也完成了由用户栈到内核栈的切换过程,同时保存了被暂停执行的程序的下一条指令。 [3] 将先前由中断向量检索得到的中断处理程序的cs,eip信息装入相应的寄存器,开始执行中断处理程序,这时就转到了内核态的程序执行了。
(因为内核控制路径使用很少的栈空间,所以只需要几千个字节的内核态堆栈。 需要注意的是,内核态堆栈仅用于内核例程,Linux内核另外为中断提供了单独的硬中断栈和软中断栈)
寄存器常见缩写:
|
|
2. 线程与进程
2.1 线程,进程,协程
进程是资源分配的基本单位,它是程序执行时的一个实例,在程序运行时创建;
线程是程序执行的最小单位,是进程的子任务,是进程的一个执行流,一般来说一个进程由多个线程组成的。
具体来说:
定义,资源隔离,创建销毁开销,切换开销通信和同步,并发性和并行性,故障影响,使用场景
- 进程是操作系统分配资源的单位,而线程是进程的一个实体,是CPU调度和分派的基本单位。
- 线程没有独立的内存单元,只拥有一点在运行中必不可少的资源,如寄存器和运行栈,不能够独立执行,必须依存在进程中。
- 执行过程:每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行。
- 资源开销:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;
- 线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。
- 包含关系:如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。
- 影响关系:一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。
对于操作系统来说,一个任务就是一个进程(Process),比如使用Word。
而一个进程可能不只干一件事(比如word既要打字又要检查拼写),这种进程内的多个子任务就是线程(Thread),进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。
系统运行一个程序即是一个进程从创建,运行到消亡的过程。简单来说,一个进程就是一个执行中的程序,它在计算机中一个指令接着一个指令地执行着,同时,每个进程还占有某些系统资源如CPU时间,内存空间,文件,文件,输入输出设备的使用权等等。
换句话说,当程序在执行时,将会被操作系统载入内存中。
Q: 一个进程最多可以创建多少个线程?
- 进程的虚拟内存空间上限,因为创建一个线程,操作系统需要为其分配一个栈空间,如果线程数量越多,所需的栈空间就要越大,那么虚拟内存就会占用的越多。
- 系统参数限制,虽然 Linux 并没有内核参数来控制单个进程创建的最大线程个数,但是有系统级别的参数来控制整个系统的最大线程个数。
多线程
举个例子,假设要编写一个视频播放器软件,那么该软件功能的核心模块有三个:
- 从视频文件当中读取数据;
- 对读取的数据进行解压缩;
- 把解压缩后的视频数据播放出来;
- 播放出来的画面和声音会不连贯,因为当 CPU 能力不够强的时候,
Read的时候可能进程就等在这了,这样就会导致等半天才进行数据解压和播放; - 各个函数之间不是并发执行,影响资源的使用效率;
Q: 多线程的好处:
并发执行,资源共享,核心利用,多任务场景,代码模块化,提高响应性
通俗地讲例子:
1.使用线程可以把占据时间长的程序中的任务放到后台去处理
2.用户界面更加吸引人,这样比如用户点击了一个按钮去触发某件事件的处理,可以弹出一个进度条来显示处理的进度
3.程序的运行效率可能会提高
4.在一些等待的任务实现上如用户输入,文件读取和网络收发数据等, 线程就比较有用了。
线程主要优点:
- 一个进程中可以同时存在多个线程;
- 各个线程之间可以并发执行;
- 各个线程之间可以共享地址空间和文件等资源;
Q: 多线程的缺点:
竞态条件,死锁和活锁,通信复杂,调试困难,性能下降,不确定性
- 如果有大量的线程,大量的上下文切换会影响性能,因为操作系统需要在它们之间切换.
- 更多的线程需要更多的内存空间
- 线程中止需要考虑对程序运行的影响. 当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃(这里是针对 C/C++ 语言,Java语言中的线程奔溃不会造成进程崩溃)
- 通常块模型数据是在多个线程间共享的, 需要防止线程死锁情况的发生
Q1. 进程中的一个线程崩溃之后所有线程都会崩溃吗?
这个不是必定发生的,假设一个进程启动了a,b,c三个线程,只要这三个线程之间的其中一个在运行过程中触发了unix的信号,比如除0异常,违规访问内存触发段错误等等,都会使得os向进程发送特定的信号,进程默认的行为是在接收到这些信号的时候退出,表象就是你所说的一个线程奔溃导致其他线程奔溃了。
但是如果a线程的奔溃没有触发操作系统向进程发送信号或者在进程中已经提前注册了对应信号的回调函数(此时收到信号进程不会按默认行为退出而且执行预设的回调函数),那么其他两个线程还是能正常地运行。
大部分情况下,其他线程并不会自己崩溃,而是操作系统检测到异常,会kill掉进程,其他线程就一起被干掉了。
小部分情况下,一个线程出错,破坏了进程中其他线程的内存,导致其他线程出现严重错误,被操作系统检测到,然后连同进程一起干掉。
进程是由内核管理和调度的,所以进程的切换只能发生在内核态。
进程上下文切换开销
Q: 为什么物理内存只有 2G,进程的虚拟内存却可以使用 25T 呢?
因为虚拟内存并不是全部都映射到物理内存的,
程序是有局部性的特性,也就是某一个时间只会执行部分代码,所以只需要映射这部分程序就好
- 32 位系统,用户态的虚拟空间只有 3G,如果创建线程时分配的栈空间是 10M,那么一个进程最多只能创建 300 个左右的线程。
- 64 位系统,用户态的虚拟空间大到有 128T,理论上不会受虚拟内存大小的限制,而会受系统的参数或性能限制。
总结:
- 一个程序至少有一个进程, 一个进程至少有一个线程。hh
- 进程在执行过程中拥有独立的内存单元,而多个线程共享内存。同一进程内的线程共享内存和文件,因此它们之间相互通信无须调用内核
- 引入线程的好处: 线程快!创建、终止、切换都很快!虽然线程拥有单独的程序运行入口,出口,但不能独立执行。


(1)用户级上下文: 正文、数据、用户堆栈以及共享存储区;
(2)寄存器上下文: 通用寄存器、程序寄存器(IP)、处理器状态寄存器(EFLAGS)、栈指针(ESP);
(3)系统级上下文: 进程控制块task_struct、内存管理信息(mm_struct、vm_area_struct、pgd、pte)、内核栈。
协程和线程
-
一个线程可以多个协程,一个进程也可以单独拥有多个协程。
-
线程进程都是同步机制,而协程则是异步。
-
协程能保留上一次调用时的状态,每次过程重入时,就相当于进入上一次调用的状态。
-
线程是抢占式,而协程是非抢占式的,所以需要用户自己释放使用权来切换到其他协程,因此同一时间其实只有一个协程拥有运行权,相当于单线程的能力。
-
协程并不是取代线程, 而且抽象于线程之上, 线程是被分割的CPU资源, 协程是组织好的代码流程, 协程需要线程来承载运行, 线程是协程的资源, 但协程不会直接使用线程, 协程直接利用的是执行器(Interceptor), 执行器可以关联任意线程或线程池, 可以使当前线程, UI线程, 或新建新程.。
-
线程是协程的资源。协程通过Interceptor来间接使用线程这个资源。
2.2 进程有哪些状态,转换条件是什么?
就绪状态:进程获得了除CPU之外的一切所需资源
运行状态:一个CPU的一个核只能有一个进程处于运行状态。
阻塞状态,又称等待状态:进程需要其他资源或正在等待某一事件发生而暂停运行。如等待某资源为可用(不包括处理机)或等待输入/输出完成。即使处理机空闲,该进程也不能运行。

注意区别就绪状态和等待状态:
就绪状态是指进程仅缺少处理机,只要获得处理机资源就立即执行;而等待状态是指进程需要其他资源(除了处理机)或等待某一事件。
Sleep()函数和wait()函数的区别
(1)属于不同的两个类,sleep()方法是线程类(Thread)的静态方法,wait()方法是Object类里的方法。
(2)sleep()方法不会释放锁,wait()方法释放对象锁。
(3)sleep()方法可以在任何地方使用,wait()方法则只能在同步方法或同步块中使用。
(4)sleep()使线程进入阻塞状态(线程睡眠),wait()方法使线程进入等待队列(线程挂起),也就是阻塞类别不同。
join()方法: join()方法使调用该方法的线程在此之前执行完毕,也就是等待该方法的线程执行完毕后再往下继续执行。注意该方法也需要捕捉异常。
yield()方法:该方法与sleep()类似,都是可以让当前正在运行的线程暂停,区别在于yield()方法不会阻塞该线程,它只是将线程转换成就绪状态,让系统的调度器重新调度一次,并且yield()方法只能让优先级相同或许更高的线程有执行的机会。
2.3 进程间通信
IPC(Inter process communication)问题,主要是指进程间交换数据的方式。
进程是相互独立的,并不需要条件变量、互斥锁这些机制,要锁也是文件锁这种大锁。
而线程需要互斥锁的原因是:线程之间的资源室共享的,需要程序员来完成变量级别的同步。
进程间通信分为低级通信和高级通信。
- 低级通信:信号量
- 高级通信:
- 管道
- 消息队列
- 共享内存
- 信号 套接字
管道(pipe)
管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
管道是指用于连接一个读进程和一个写进程的一个共享文件,又名pipe文件,以字符流形式将数据写入文件。
管道分为无名管道和有名管道:匿名管道就是内核⾥⾯的⼀串缓存。从管道的⼀段写⼊的数据,实际上是缓存在内核中的,另⼀端读取,也就是从内核中读取这段数据。另外,管道传输的数据是⽆格式的流且⼤⼩受限。
对于匿名管道,它的通信范围是存在⽗⼦关系的进程。因为管道没有实体,也就是没有管道⽂件,只能通过 fork 来复制⽗进程 fd ⽂件描述符,来达到通信的⽬的。
另外,对于命名管道,它可以在不相关的进程间也能相互通信。因为命令管道,提前创建了⼀个类型为管道的设备⽂件,在进程⾥只要使⽤这个设备⽂件,就可以相互通信。在 shell ⾥⾯执⾏ A | B 命令的时候,A 进程和 B 进程都是 shell 创建出来的⼦进程,A 和 B 之间不存在⽗⼦关系,它俩的⽗进程都是 shell。
- 匿名管道是半双工的通信方式,数据只能单向流动,只能在父子进程中流通;
- 有名管道也是半双工,但是它允许无亲缘关系进程间通信。
通信⽅式是效率低的,因此管道不适合进程间频繁地交换数据。
所谓的管道,就是内核⾥⾯的⼀串缓存。 读写效率低,因此管道不适合进程间频繁地交换数据。
创建的⼦进程会复制⽗进程的⽂件描述符。
对于匿名管道,它的通信范围是存在⽗⼦关系的进程,
对于命名管道,它可以在不相关的进程间也能相互通信。
不管是匿名管道还是命名管道,进程写⼊的数据都是缓存在内核中,另⼀个进程读取数据时候⾃然也是从内核中获取,同时通信数据都遵循先进先出原则。
消息队列(messagequeue)
消息队列是由消息的链表,存放在内核中并由消息队列标识符标识,消息体是⽤户⾃定义的数据类型。
消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
消息队列指的是进程间的数据交换是以格式化的消息(Message)为单位的,再由消息组成的链表,形成队列。
消息队列是保存在内核中的消息链表
⼀:通信不及时
⼆:消息也有⼤⼩限制,这同样也是消息队列通信不⾜的点,
三:消息队列通信过程中,存在⽤户态与内核态之间的数据拷⻉开销。
消息队列不适合⽐较⼤数据的传输,因为在内核中每个消息体都有⼀个最⼤⻓度的限制,同时所有队列所包含的全部消息体的总⻓度也是有上限。
共享内存(shared memory)
共享内存的机制,就是拿出⼀块虚拟地址空间来,映射到相同的物理内存中,这段共享内存由一个进程创建,但多个进程都可以访问。
共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。
⽤了共享内存通信⽅式,带来新的问题,那就是如果多个进程同时修改同⼀个共享内存,很有可能就冲突
例如两个进程都同时写⼀个地址,那先写的那个进程会发现内容被别⼈覆盖了。
它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。
共享内存指在通信的进程之间存在一块可直接访问的共享空间,通过对这片共享空间进行写/读操作实现进程之间的信息交换。
在对共享空间进行写/读操作时,需要使用同步互斥工具**(如 P操作、V操作)**,对共享空间的写/读进行控制。
P(S):①将信号量S的值减1,即S=S-1;
②如果S>=0,则该进程继续执行;否则该进程置为等待状态,排入等待队列。
V(S):①将信号量S的值加1,即S=S+1;
②如果S>0,则该进程继续执行;否则释放队列中第一个等待信号量的进程。
信号量(semaphore)
信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
信号量是一个计数器,防止多个进程将资源拿光,防止某进程正在访问共享资源时,其他进程也访问该资源。
为了防⽌多进程竞争共享资源,⽽造成的数据错乱,所以需要保护机制,使得共享 的资源,在任意时刻只能被⼀个进程访问。正好,信号量就实现了这⼀保护机制。
⼀个是 P 操作,这个操作会把信号量减去 1,相减后如果信号量 < 0,则表明资源已被占⽤,进程需
阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使⽤,进程可正常继续执⾏。
另⼀个是 V 操作,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运⾏;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;
信号初始化为 1 ,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有⼀个进程在访问,这就很好的保护了共享内存。
信号初始化为 0 ,就代表着是同步信号量,它可以保证进程 A 应在进程 B 之前执⾏
信号 (sinal)
**信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。**上⾯说的进程间通信,都是常规状态下的⼯作模式。
**对于异常情况下的⼯作模式,就需要⽤「信号」的⽅**式来通知进程。信号是进程间通信机制中唯⼀的异步通信机制,
1.执⾏默认操作。Linux 对每种信号都规定了默认操作,例如,上⾯列表中的 SIGTERM 信号,就是终⽌进程的意思。
2.捕捉信号。我们可以为信号定义⼀个信号处理函数。当信号发⽣时,我们就执⾏相应的信号处理函数。
3.忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。
有两个信号是应⽤进程⽆法捕捉和忽略的,即 SIGKILL 和 SEGSTOP ,它们⽤于在任何时候中断或结束某⼀进程。
Socket(套接字)
套接口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同设备及其间的进程通信。
那要想跨⽹络与不同主机上的进程之间通信,就需要 Socket 通信了。
- 服务端和客户端初始化
socket,得到文件描述符; - 服务端调用
bind,绑定 IP 地址和端口,协议; - 服务端调用
listen,进行监听; - 服务端调用
accept,等待客户端连接; - 客户端调用
connect,向服务器端的地址和端口发起连接请求; - 服务端
accept返回用于传输的socket的文件描述符; - 客户端调用
write写入数据;服务端调用read读取数据; - 客户端断开连接时,会调用
close,那么服务端read读取数据的时候,就会读取到了EOF,待处理完数据后,服务端调用close,表示连接关闭。

image-20210706201229857

2.4 进程间同步(通信主要为了同步)
多进程虽然提高了系统资源利用率和吞吐量,但是由于进程的异步性可能造成系统的混乱。进程同步的任务就是对多个相关进程在执行顺序上进行协调。
进程是相互独立的,所以进程间通信大多不需要锁,需要的锁也是文件锁之类的“大锁”,并不需要条件变量、互斥锁这些机制来同步
2.5 线程间同步和通信

由于线程间的资源可以共享,同步的方式就会更加细致:
- 互斥量 互斥与临界区很相似,但是使用时相对复杂一些(互斥量为内核对象),不仅可以在同一应用程序的线程间实现同步,还可以在不同的进程间实现同步,从而实现资源的安全共享。 由于互斥量是内核对象,因此其可以进行进程间通信,同时还具有一个很好的特性,就是在进程间通信时完美的解决了“遗弃”问题
- 信号量,只能用于一个资源的互斥访问,不能实现多个资源的多线程互斥问题。信号量的用法和互斥的用法很相似,不同的是它可以同一时刻允许多个线程访问同一个资源,PV操作。
- 读写锁,可以被多个读者拥有,但是只能被一个写者拥有的锁
- 条件变量,线程 A 等待某个条件并挂起,直到线程 B 设置了这个条件,并通知条件变量,然后线程 A 被唤醒
- 原子操作PV:
- 通道:
- 事件:
1. 临界区
每个进程中访问临界资源的那段程序称为临界区,一次仅允许一个进程使用的资源称为临界资源。
解决冲突的办法:
- 如果有若干进程要求进入空闲的临界区,一次仅允许一个进程进入,如已有进程进入自己的临界区,则其它所有试图进入临界区的进程必须等待;
- 进入临界区的进程要在有限时间内退出。
- 如果进程不能进入自己的临界区,则应让出CPU,避免进程出现“忙等”现象。
临界区指的是一个访问共用资源(例如:共用设备或是共用存储器)的程序片段,而这些共用资源又无法同时被多个线程访问的特性
任何想进⼊临界区的线程,必须先执⾏加锁操作。 若加锁操作顺利通过,则线程可进⼊临界区;
在完成对临界资源的访问后再执⾏解锁操作,以释放该临界资源。
- 互斥锁加锁失败后,线程会释放 CPU ,给其他线程;
- 自旋锁加锁失败后,线程会忙等待,直到它拿到锁;
当获取不到锁时,线程就会⼀直 wile 循环,不做任何事情,所以就被称为**「忙等待锁」,也被称为⾃旋锁(spin lock)**。
既然不想⾃旋,那当没获取到锁的时候,就把当前线程放⼊到锁的等待队列,然后执⾏调度程序,把 CPU让给其他线程执⾏。(互斥锁)
临界区对应着一个CcriticalSection对象。当线程需要访问保护数据时,调用EnterCriticalSection函数;当对保护数据的操作完成之后,调用LeaveCriticalSection函数释放对临界区对象的拥有权,以使另一个线程可以夺取临界区对象并访问受保护的数据。
关键段对象会记录拥有该对象的线程句柄即其具有“线程所有权”概念,即进入代码段的线程在leave之前,可以重复进入关键代码区域。所以关键段可以用于线程间的互斥,但不可以用于同步(同步需要在一个线程进入,在另一个线程leave)
2. 互斥量
互斥锁(又名互斥量)强调的是资源的访问互斥:互斥锁是用在多线程多任务互斥的,一个线程占用了某一个资源,那么别的线程就无法访问,直到这个线程unlock,其他的线程才开始可以利用这个资源。
比如对全局变量的访问,有时要加锁,操作完了,在解锁。有的时候锁和信号量会同时使用的。
也就是说,信号量不一定是锁定某一个资源,而是流程上的概念,比如:有A,B两个线程,B线程要等A线程完成某一任务以后再进行自己下面的步骤,这个任务并不一定是锁定某一资源,还可以是进行一些计算或者数据处理之类。而线程互斥量则是“锁住某一资源”的概念,在锁定期间内,其他线程无法对被保护的数据进行操作。在有些情况下两者可以互换。
在linux下, 线程的互斥量数据类型是pthread_mutex_t. 在使用前, 要对它进行初始化:
对于静态分配的互斥量, 可以把它设置为PTHREAD_MUTEX_INITIALIZER, 或者调用pthread_mutex_init.
对于动态分配的互斥量, 在申请内存(malloc)之后, 通过pthread_mutex_init进行初始化, 并且在释放内存(free)前需要调用pthread_mutex_destroy.采用互斥对象机制
只有拥有了互斥对象的线程才有访问资源的权限。因为互斥对象只有一个
所以可以保证公共资源不会被多个线程同时访问,互斥量本质上是一把锁,在访问共享资源前对互斥量进行加锁,在访问完成后释放互斥量上的锁。

哲学家进餐问题:
拿起叉⼦⽤ P 操作,代表有叉⼦就直接⽤,没有叉⼦时就等待其他哲学家放回叉⼦。

不过,这种解法存在一个极端的问题:假设五位哲学家同时拿起左边的叉子,桌面上就没有叉子了, 这样就没有人能够拿到他们右边的叉子,也就说每一位哲学家都会在 P(fork[(i + 1) % N ]) 这条语句阻塞了,很明显这发生了死锁的现象。

上⾯程序中的互斥信号量的作⽤就在于,只要有⼀个哲学家进⼊了「临界区」,也就是准备要拿叉⼦时,
其他哲学家都不能动,只有这位哲学家⽤完叉⼦了,才能轮到下⼀个哲学家进餐
会导致只能允许⼀个哲学家就餐,那么我们就不⽤它。
⽅案⼀的问题在于,会出现所有哲学家同时拿左边⼑叉的可能性,那我们就避免哲学家可以同时拿
左边的⼑叉,采⽤分⽀结构,根据哲学家的编号的不同,⽽采取不同的动作。
即让偶数编号的哲学家「先拿左边的叉⼦后拿右边的叉⼦」,奇数编号的哲学家「先拿右边的叉⼦后拿左边的叉⼦」。
在 P 操作时,根据哲学家的编号不同,拿起左右两边叉⼦的顺序不同。另外,V 操作是不需要分⽀的,因为 V 操作是不会阻塞的。
方案三即不会出现死锁,也可以两人同时进餐。

方案四
在这里再提出另外一种可行的解决方案,我们用一个数组 state 来记录每一位哲学家的三个状态,分别是在进餐状态、思考状态、饥饿状态(正在试图拿叉子)。
那么,一个哲学家只有在两个邻居都没有进餐时,才可以进入进餐状态。
第 i 个哲学家的左邻右舍,则由宏 LEFT 和 RIGHT 定义:
- LEFT : ( i + 5 - 1 ) % 5
- RIGHT : ( i + 1 ) % 5
比如 i 为 2,则 LEFT 为 1,RIGHT 为 3。


3.信号量
信号量允许同一时刻多个线程访问同一个资源,但是要控制最大线程数量

- Ctrl+C 产生
SIGINT信号,表示终止该进程; - Ctrl+Z 产生
SIGTSTP信号,表示停止该进程,但还未结束;
对于两个并发线程,互斥信号量的值仅取 1、0 和 -1 三个值,分别表示:
- 如果互斥信号量为 1,表示没有线程进入临界区;
- 如果互斥信号量为 0,表示有一个线程进入临界区;
- 如果互斥信号量为 -1,表示一个线程进入临界区,另一个线程等待进入。
通过互斥信号量的方式,就能保证临界区任何时刻只有一个线程在执行,就达到了互斥的效果。
如果进程在后台运行,可以通过 kill 命令的方式给进程发送信号,但前提需要知道运行中的进程 PID 号,例如:
- kill -9 1050 ,表示给 PID 为 1050 的进程发送
SIGKILL信号,用来立即结束该进程;
所以,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令)。
4. 信号
信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。
1.执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。
2.捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。
3.忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。
有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL 和 SEGSTOP,它们用于在任何时候中断或结束某一进程
5. 读写锁
读写锁与互斥量类似,不过读写锁允许更高的并行性。
互斥量要么是锁住状态要么是不加锁状态,而且一次只有一个线程可以对其加锁。
读写锁可以由三种状态:读模式下加锁状态、写模式下加锁状态、不加锁状态。
先进先出,一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。
既然读者优先策略和写者优先策略都会造成饥饿的现象,那么我们就来实现。

6. 条件变量(condition)
条件变量是用来等待而不是用来上锁的,条件变量是利用线程间共享的全局变量进行同步的一种机制,条件变量与互斥量一起使用时,允许线程等待特定的条件发生。条件变量与互斥量一起使用时,允许线程以无竞争的方式等待特定的条件发生。
条件本身是由互斥量保护的。线程在改变条件状态前必须首先锁住互斥量,其它线程在获得互斥量之前不会察觉到这种改变,因此必须锁定互斥量以后才能计算条件。
条件的检测是在互斥锁的保护下进行的。如果一个条件为假,一个线程自动阻塞,并释放等待状态改变的互斥锁。如果另一个线程改变了条件,它发信号给关联的条件变量,唤醒一个或多个等待它的线程,重新获得互斥锁,重新评价条件。
如果两进程共享可读写的内存,条件变量可以被用来实现这两进程间的线程同步。
-
如果线程正在等待共享数据内某个条件出现,那会发生什么呢?
代码可以反复对互斥对象锁定和解锁, 以检查值的任何变化。同时,还要快速将互斥对象解锁,以便其它线程能够进行任何必需的更改。需要一种方法以唤醒因等待满足特定条件而睡眠的线程。
7. 事件
通过通知操作的方式来保持线程的同步
事件是内核对象,可以解决线程间同步问题,因此也能解决互斥问题。
|
|
1、使用全局变量
主要由于多个线程可能更改全局变量,因此全局变量最好声明为volatile。
2、使用消息实现通信
在Windows程序设计中,每一个线程都可以拥有自己的消息队列(UI线程默认自带消息队列和消息循环,工作线程需要手动实现消息循环),因此可以采用消息进行线程间通信sendMessage,postMessage。
2.7 线程的分类
内核级线程:
这类线程依赖于内核,又称为内核支持的线程或轻量级进程。
无论是在用户程序中的线程还是系统进程中的线程,它们的创建、撤销和切换都由内核实现。
比如英特尔i5-8250U是4核8线程,这里的线程就是内核级线程。
内核线程是由操作系统管理的,线程对应的 TCB ⾃然是放在操作系统⾥的,这样线程的创建、终⽌和管理都是由操作系统负责。
内核线程的优点:
- 在⼀个进程当中,如果某个内核线程发起系统调⽤⽽被阻塞,并不会影响其他内核线程的运⾏;
- 分配给线程,多线程的进程获得更多的 CPU 运⾏时间;
内核线程的缺点:
- 在⽀持内核线程的操作系统中,由内核来维护进程和线程的上下⽂信息,如 PCB 和 TCB;
- 线程的创建、终⽌和切换都是通过系统调⽤的⽅式来进⾏,因此对于系统来说,系统开销⽐较⼤;
用户级线程:
它仅存在于用户级中,这种线程是不依赖于操作系统核心的。应用进程利用线程库来完成其创建和管理,速度比较快,操作系统内核无法感知用户级线程的存在。
⽤户线程是基于⽤户态的线程管理库来实现的,那么线程控制块(Thread Control Block, TCB) 也是在库⾥⾯来实现的,对于操作系统⽽⾔是看不到这个 TCB 的,它只能看到整个进程的 PCB。
⽤户线程的优点:
- 每个进程都需要有它私有的线程控制块(TCB)列表,⽤来跟踪记录它各个线程状态信息(PC、栈指针、寄存器),TCB 由⽤户级线程库函数来维护,可⽤于不⽀持线程技术的操作系统;
- ⽤户线程的切换也是由线程库函数来完成的,⽆需⽤户态与内核态的切换,所以速度特别快;
⽤户线程的缺点:
- 由于操作系统不参与用户级线程的调度,如果⼀个线程发起了系统调⽤⽽阻塞,那**进程所包含的⽤户线程都不能执⾏**了。
- 当⼀个线程开始运⾏后,除⾮它主动地交出 CPU 的使⽤权,否则它所在的进程当中的其他线程⽆法运⾏,因为⽤户态的线程没法打断当前运⾏中的线程,它没有这个特权,只有操作系统才有,但是⽤户线程不是由操作系统管理的。
- 由于时间⽚分配给进程,故与其他进程⽐,在多线程执⾏时,每个线程得到的时间⽚较少,执⾏会⽐较慢;
轻量级进程(Light-weight process,LWP)
是内核⽀持的⽤户线程,⼀个进程可有⼀个或多个** LWP,每个 LWP 是跟内核线程⼀对⼀映射的,也就是 LWP 都是由⼀个内核线程⽀持。
另外,LWP 只能由内核管理并像普通进程⼀样被调度,Linux 内核是⽀持 LWP 的典型例⼦。
在⼤多数系统中,LWP与普通进程的区别也在于它只有⼀个最⼩的执⾏上下⽂和调度程序所需的统计信息。
⼀般来说,⼀个进程代表程序的⼀个实例,⽽ LWP 代表程序的执⾏线程.
因为⼀个执⾏线程不像进程那样需要那么多状态信息,所以 LWP 也不带有这样的信息。
在 LWP 之上也是可以使⽤⽤户线程的,那么 LWP 与⽤户线程的对应关系就有三种:
1 : 1 ,即⼀个 LWP 对应 ⼀个⽤户线程;
N : 1 ,即⼀个 LWP 对应多个⽤户线程;
M : N ,即多个 LMP 对应多个⽤户线程;
2.8 线程池
线程池就是提前创建若干个线程,如果有任务需要处理,线程池里的线程就会处理任务,处理完之后线程并不会被销毁,而是等待下一个任务。
为了减少创建和销毁线程的次数,让每个线程可以多次使用,同时可根据系统情况调整执行的线程数量,防止消耗过多内存。
由于创建和销毁线程都是消耗系统资源的,所以池化技术能提升性能。
线程池的组成主要分为 3 个部分,这三部分配合工作就可以得到一个完整的线程池:
- 任务队列,存储需要处理的任务,由工作的线程来处理这些任务
- 通过线程池提供的 API 函数,将一个待处理的任务添加到任务队列,或者从任务队列中删除
- 已处理的任务会被从任务队列中删除
- 线程池的使用者,也就是调用线程池函数往任务队列中添加任务的线程就是生产者线程
- 工作的线程(任务队列任务的消费者) ,N个
- 1.线程池中维护了一定数量的工作线程,他们的作用是是不停的读任务队列,从里边取出任务并处理.
- 2.工作的线程相当于是任务队列的消费者角色,
- 3.如果任务队列为空,工作的线程将会被阻塞 (使用条件变量 / 信号量阻塞)
- 4.如果阻塞之后有了新的任务,由生产者将阻塞解除,工作线程开始工作
- 管理者线程(不处理任务队列中的任务),1个
- 它的任务是周期性的对任务队列中的任务数量以及处于忙状态的工作线程个数进行检测
- 当任务过多的时候,可以适当的创建一些新的工作线程
- 当任务过少的时候,可以适当的销毁一些工作的线程
2.9 进程调度
批处理系统、分时系统和实时系统中,各采用哪几种进程(作业)调度算法?
批处理系统常用调度算法:
①、先来先服务:FCFS ②、最短作业优先 ③、最短剩余时间优先 ④、响应比最高者优先
分时系统调度算法:
①、轮转调度 ②、优先级调度 ③、多级队列调度 ④、彩票调度
实时系统调度算法:
①、单比率调度 ②、限期调度 ③、最少裕度法








.

2.10 多线程冲突了怎么办
由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为临界区(critical section),它是访问共享资源的代码片段,一定不能给多线程同时执行。
我们希望这段代码是互斥(mutualexclusion)的,也就说保证一个线程在临界区执行时,其他线程应该被阻止进入临界区,说白了,就是这段代码执行过程中,最多只能出现一个线程。
- 锁:加锁、解锁操作;
- 信号量:P、V 操作;
根据锁的实现不同,可以分为「忙等待锁」和「无忙等待锁」。
那什么是原子操作呢?原子操作就是要么全部执行,要么都不执行,不能出现执行到一半的中间状态

PV 操作的算法描述
多线程,为什么要用多线程
- 提高系统的并发性:多线程可以使系统同时执行多个任务,提高系统的并发性和响应能力。当一个线程被阻塞或等待某个操作完成时,其他线程可以继续执行,充分利用处理器的资源,提高系统整体的吞吐量。
- 改善用户体验:多线程可以使复杂的任务在后台运行,而不阻塞用户界面的响应。例如,在图形界面应用程序中,可以使用一个线程来处理用户界面事件和响应,另一个线程来执行耗时的计算或网络操作,这样用户可以同时进行交互而不会感到应用程序卡顿。
- 提高程序的执行效率:通过并行执行多个任务,可以充分利用多核处理器的能力,加快程序的执行速度。对于需要进行大量计算或密集的I/O操作的任务,通过多线程可以将任务分解为多个子任务并行执行,从而减少总体执行时间。
- 实现异步编程:多线程可以用于实现异步编程模型,其中一个线程可以执行长时间运行的操作,而其他线程可以继续执行其他任务。这种模型在处理网络请求、文件操作、数据库查询等需要等待外部资源响应的情况下非常有用,可以提高应用程序的性能和响应能力。
- 分解复杂任务:多线程可以将一个复杂的任务分解为多个独立的子任务,并使用不同的线程同时执行这些子任务。这样可以简化任务的管理和实现,并且可以更好地利用系统资源。
3. 内存管理
- 内存(memory)资源永远都是稀缺的,当越来越多的进程需要越来越来内存时,某些进程会因为得不到内存而无法运行;
- 内存容易被破坏,一个进程可能误踩其他进程的内存空间;
为了在多进程环境下,使得进程之间的内存地址不受影响,相互隔离,内存短缺 + 内存访问需要做保护,于是操作系统就为每个进程独立分配一套虚拟地址空间。
操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存
每个进程都有自己的虚拟空间,而物理内存只有一个,所以当启用了大量的进程,物理内存必然会很紧张,于是操作系统会通过内存交换技术,把不常使用的内存暂时存放到硬盘(换出),在需要的时候再装载回物理内存(换入)。
- 高效使用内存:VM将主存看成是存储在磁盘上的地址空间的高速缓存,主存中保存热的数据,根据需要在磁盘和主存之间传送数据;
- 简化内存管理:VM为每个进程提供了一致的地址空间,从而简化了链接、加载、内存共享等过程;
- 内存保护:通过在页表条目中加入保护位,保护每个进程的地址空间不被其他进程破坏。
3.1 逻辑地址、线性地址和物理地址的区别?
逻辑地址(Logic Address)是指由程序产生的与段相关的偏移地址部分,因此一个逻辑地址由段标识符和段内偏移量组成,有时也称虚拟地址。
比如,在C程序中,可以使用&操作读取指针变量本身的值,实际上这个值就是逻辑地址。逻辑地址和绝对的物理地址不相干。
程序经过编译后,每个目标模块都是从0号单元开始编址,称为该目标模块的相对地址(或逻辑地址)。要通过分段地址的变化处理+分页后才会对应到相应的物理内存地址。
线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。
程序代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。 如果启用了分页机制,那么线性地址可以再经过变换产生物理地址。
若是没有采用分页机制,那么线性地址就是物理地址。
物理地址(Physical Address)是指内存中物理单元的集合,它是地址转换的最终地址,是CPU外部地址总线上的地址。进程在运行时执行指令和访问数据都要通过物理地址从主存中存取
逻辑(虚拟)地址经过分段(查询段表)转化为线性地址。线性地址经过分页(查询页表)转为物理地址。
分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。
这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB。
虚拟地址与物理地址之间通过页表来映射。
3.2 寻址方式有哪些?
寻址寻的都是物理地址。
分三组:立即寻址+寄存器寻址;
直接间接寻址; 相对寻址+2个基变址寻址。
3.3 什么是虚拟内存?
虚拟内存是一种计算机系统内存管理技术。它使得应用程序认为它拥有连续可用的内存,即一个连续完整的地址空间。
而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。多任务会带来进程对内存的操作冲突,需要虚拟内存来解决。
假设现在有一块物理内存,操作系统让两个进程共用这一块内存,彼此并不打扰。
- 虚拟内存地址空间是连续的,没有碎片
- 虚拟内存的最大空间就是cpu的最大寻址空间,不受内存大小的限制,能提供比内存更大的地址空间
电脑中所运行的程序均需经过内存执行,若执行的程序占用的内存很大很多,则会导致内存消耗殆尽,为解决该问题,WINDOWS运用了虚拟内存技术,即拿出一部分硬盘空间来充当内存使用,这部分空间即称为虚拟内存。
-
优点:可以弥补物理内存大小的不足;加载或交换每个用户程序到内存所需的 I/O 会更少一定程度的提高反映速度;
用户可以为一个巨大的虚拟地址空间编写程序,同时运行更多的程序,进而增加 CPU 利用率和吞吐量,但没有增加响应时间或周转时间**,减少对物理内存的读取从而保护内存延长内存使用寿命**;
-
缺点:占用一定的物理硬盘空间;加大了对硬盘的读写;设置不得当会影响整机稳定性与速度。
虚拟内存技术允许执行进程不必完全处于内存。
这种方案的一个主要优点就是,程序可以大于物理内存。
此外,虚拟内存将内存抽象成一个巨大的、统一的存储数组,进而实现了用户看到的逻辑内存与物理内存的分离。这种技术使得程序员不再担忧内存容量的限制。
虚拟内存还允许进程轻松共享文件和实现共享内存
3.3.1 虚拟内存作用
1、安全隔离,进程访问自身的私有内存片
2、共享内存,在进程之间有效共享代码库
3、善用碎片空间,更有效率地使用主存能够创建给主存更多的空间,每个进程都独有一个虚拟内存,并且解决主存非连续空间分配内存给某进程善用碎片空间
4、可作为缓存用,但需要进程通过页表进行翻译,这个时候需要在通过硬件进行缓存如TLB
Q: 假设没有虚拟内存会怎么样?
1、当一个进程需要的空间少于主存的时候,运行正常
2、当一个进程需要读取非常大的文件的时候,主存不够大,这个时候就出现缺页,切换进行效率好差
3、当有3个进程,划分了n分空间,第四个进程没有连续空间进行划分这个时候就会出现创建不了进程,甚至出现频繁切换进程
【总结】
运行更安全进程独立内存地址空间,善用碎片的内存空间,从而运行更多进程提高效率

3.4 什么是交换空间?
操作系统把物理内存(physical RAM)分成一块一块的小内存,每一块内存被称为页(page)。
当内存资源不足时,Linux把某些页的内容转移至硬盘上的一块空间上,以释放内存空间。
硬盘上的那块空间叫做交换空间(swap space), 而这一过程被称为交换(swapping)。
物理内存和交换空间的总容量就是虚拟内存的可用容量。
用途:
- 物理内存不足时一些不常用的页可以被交换出去,腾给系统。
- 程序启动时很多内存页被用来初始化,之后便不再需要,可以交换出去。
3.5 什么是分页?
把内存空间划分为大小相等且固定的块,作为主存的基本单位。
因为程序数据存储在不同的页面中,而页面又离散的分布在内存中,因此需要一个页表来记录映射关系,以实现从页号到物理块号的映射。
访问分页系统中内存数据需要两次的内存访问.
(第一次是从内存中访问页表,从中找到指定的物理块号,加上页内偏移得到实际物理地址;
第二次就是根据第一次得到的物理地址访问内存取出数据。)

分段的好处就是能产生连续的内存空间,但是会出现内存碎片和内存交换的空间太大的问题。
分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB。
页表是存储在内存里的,内存管理单元 (MMU)就做将虚拟内存地址转换成物理地址的工作。
而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
由于内存空间都是预先划分好的,也就不会像分段会产生间隙非常小的内存,这正是分段会产生内存碎片的原因。
而采用了分页,那么释放的内存都是以页为单位释放的,也就不会产生无法给进程使用的小内存。
如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。
一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。
总结一下,对于一个内存地址转换,其实就是这样三个步骤:
- 把虚拟内存地址,切分成页号和偏移量;
- 根据页号,从页表里面,查询对应的物理页号;
- 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。
因为操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大。
在 32 位的环境下,虚拟地址空间共有 4GB(2^32),假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页
每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。
这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。
那么,100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。
3.6 什么是分段?
分页是为了提高内存利用率,而分段是为了满足程序员在编写代码的时候的一些逻辑需求(比如数据共享,数据保护,动态链接等)。
分段内存管理当中,地址是二维的,一维是段号,二维是段内地址;其中每个段的长度是不一样的,而且每个段内部都是从0开始编址的。
由于分段管理中,每个段内部是连续内存分配,但是段和段之间是离散分配的,因此也存在一个逻辑地址到物理地址的映射关系,相应的就是段表机制。

段选择⼦就保存在段寄存器⾥⾯。段选择⼦⾥⾯最重要的是段号,⽤作段表的索引。
段表⾥⾯保存的是这个段的基地址、段的界限和特权等级等。
虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址
第⼀个就是**外部内存碎⽚**的问题。
第⼆个就是内存交换的效率低的问题。
分段机制下的虚拟地址由两部分组成,段选择子和段内偏移量。
- 段选择子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。
- 虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。
这里的内存碎片的问题共有两处地方:
-
外部内存碎片,也就是产生了多个不连续的小物理内存,导致新的程序无法被装载;
-
内部内存碎片,程序所有的内存都被装载到了物理内存,但是这个程序有部分的内存可能并不是很常使用,这也会导致内存的浪费;
-
对于多进程的系统来说,用分段的方式,内存碎片是很容易产生的,产生了内存碎片,那不得不重新
Swap内存区域,这个过程会产生性能瓶颈。因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。
所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。
为了解决内存分段的内存碎片和内存交换效率低的问题,就出现了内存分页。
3.7 分页分段的区别是什么?
- 属性:页是信息的物理单位,对用户不可见,段是逻辑单位,用户可见。
- 大小:分页固定,分段不固定
- 决定权:分页在于系统,分段在于用户
- 目的:分页有利于资源的利用,分段方便用户管理内存
-
目的
页是信息的物理单位,分页是为实现离散分配方式,以消减内存的外零头,提高内存的利用率。或者说,分页是出于系统管理的需要而不是用户需要。
段是信息的逻辑单位,它含有一组其意义相对完整的信息。分段的目的是为了更好地满足用户的需要。
-
长度
页的大小固定而且由系统决定,由系统把逻辑地址划分为页号和页内地址两部分,是由机器硬件实现的,因而在系统中只能有一种大小的页面。
段的长度不固定,决定于用户所编写的程序,通常由编译程序在对程序进行编译时,根据信息的性质来划分。
-
地址空间
页的地址空间是一维的,即单一的线形地址空间,程序员只要利用一个记忆符就可以表示一个地址。
作业段地址空间是二维的,程序员在标识一个地址时,既需要给出段名,又需给出段内地址。
-
碎片
分页有内部碎片无外部碎片
分段有外部碎片无内部碎片
-
绝对地址
处理器使用页号和偏移量计算绝对地址
处理器使用段号和偏移量计算绝对地址
-
管理方式
对于分页,操作系统必须为每个进程维护一个页表,以说明每个页对应的的页框。
当进程运行时,它的所有页都必须在内存中,除非使用覆盖技术或虚拟技术,另外操作系统需要维护一个空闲页框列表。
对于分段,操作系统必须为每个进程维护一个段表,以说明每个段的加载地址和长度。当进程运行时,它的所有短都必须在内存中,除非使用覆盖技术或虚拟技术,另外操作系统需要维护一个内存中的空闲的空洞列表。
特别的,当使用虚拟技术是,把一页或一段写入内存时可能需要把一页或几个段写入磁盘。
-
共享和动态链接
分页不容易实现,分段容易实现。
3.8 有哪些页面置换算法?
缺页中断:在请求分页系统中,可以通过查询页表中的状态位来确定所要访问的页面是否存在于内存中。每当所要访问的页面不在内存是,会产生一次缺页中断,此时操作系统会根据页表中的外存地址在外存中找到所缺的一页,将其调入内存。
有时候操作系统必须在内存选择一个页面将其移出内存,以便为即将调入的页面让出空间。而用来选择淘汰哪一页的规则叫做页面置换算法。
-
最佳置换算法(OPT)(理想置换算法):从主存中移出永远不再需要的页面;如无这样的页面存在,则选择最长时间不需要访问的页面。于所选择的被淘汰页面将是以后永不使用的,或者是在最长时间内不再被访问的页面,这样可以保证获得最低的缺页率。 最佳页面置换算法作用是为了衡量你的算法的效率,你的算法效率越接近该算法的效率,那么说明你的算法是高效的。
-
先进先出FIFO:总是选择在主存中停留时间最长(即最老)的一页置换
-
LRU:选择在最近一段时间里最久没有使用过的页面予以置换最佳页面置换算法作用是为了衡量你的算法的效率,你的算法效率越接近该算法的效率,那么说明你的算法是高效的。
-
LFU(least ):统计页的使用频率,选择在最近时期使用最少的页面作为淘汰页
-
NRU(Not Recently Used):最近未用算法,通过给每一个访问的页面关联一个附加位(reference bit),有些地方也叫做使用位(use bit)。
主要思想是:当某一页装入主存时,将use bit置成1;如果该页之后又被访问到,使用位也还是标记成1。对于页面置换算法,候选的帧集合可以看成是一个循环缓冲区,并且有一个指针和缓冲区相关联。遇到页面替换时,指针指向缓冲区的下一帧。如果这页进入主存后发现没有空余的帧(frame),即所有页面的使用位均为1,那么这时候从指针开始循环一个缓冲区,将之前的使用位都清0,并且留在最初的位置上,换出该桢对应的页。
-
改进NRU:在之前的CLOCK算法上面除了使用位(used bit),还增加了一个修改位(modified bit),有些地方也叫做dirty bit。现在每一页有两个状态,分别**是(使用位,修改位)***,可分为以下四种情况考虑:

刚刚换出的页面马上又要换入内存,刚刚换入的页面马上又要换出外存,这种频繁的页面调度行为称为抖动,或颠簸。
产生抖动的主要原因是进程频繁访问的页面数目高于可用的物理块数(分配给进程的物理块不够)
3.9 段⻚式内存管理
先将程序划分为多个有逻辑意义的段,也就是前⾯提到的分段机制;
接着再把每个段划分为多个⻚,也就是对分段划分出来的连续空间,再划分固定⼤⼩的⻚;
第⼀次访问段表,得到⻚表起始地址;
第⼆次访问⻚表,得到物理⻚号;
第三次将物理⻚号与⻚内位移组合,得到物理地址。
可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率。
Linux 内存主要采⽤的是⻚式内存管理,但同时也不可避免地涉及了段机制。
这主要是上⾯ Intel 处理器发展历史导致的,因为 Intel X86 CPU ⼀律对程序中使⽤的地址先进⾏段式映射,然后才能进⾏⻚式映射。既然 CPU 的硬件结构是这样,Linux 内核也只好服从 Intel 的选择。
但是事实上,Linux 内核所采取的办法是使段式映射的过程实际上不起什么作⽤。也就是说,“上有政策,下有对策”,若惹不起就躲着⾛。
Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是⼀样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应⽤程序代码,所⾯对的
地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被⽤于访问控制和内存保护。这样虽然增加了硬件成本和系统开销,但提⾼了内存的利⽤率。


3.10 Cache
多级⻚表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了⼏道转换的⼯序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。
CPU 芯⽚中,加⼊了⼀个专⻔存放程序最常访问的⻚表项的 Cache,这个 Cache 就是 TLB
(Translation Lookaside Buffer) ,通常称为⻚表缓存、转址旁路缓存、快表等。

在 CPU 芯片里面,封装了内存管理单元(Memory Management Unit)芯片,它用来完成地址转换和 TLB 的访问与交互。
有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。
TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。
3.11 Linux内存分布
- 程序文件段,包括二进制可执行代码;
- 已初始化数据段,包括静态常量;
- 未初始化数据段,包括未初始化的静态变量;
- 堆段,包括动态分配的内存,从低地址开始向上增长;
- 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关 );
- 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是
8 MB。当然系统也提供了参数,以便我们自定义大小;
在这 6 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc()(br()) 或者 mmap() ,就可以分别在堆和文件映射段动态分配内存。
3.12 malloc 是如何分配内存的?
实际上,malloc() 并不是系统调用,而是 C 库里的函数,用于动态分配内存。
malloc 申请内存的时候,会有两种方式向操作系统申请堆内存。
- 方式一:通过 brk() 系统调用从堆分配内存
- 方式二:通过 mmap() 系统调用在文件映射区域分配内存;
方式一 实现的方式很简单,就是通过 brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间。如下图:

方式二 通过 mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区“偷”了一块内存。如下图:

malloc() 源码里默认定义了一个阈值:
- 如果用户分配的内存小于 128 KB,则通过 brk() 申请内存;
- 如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存;
3.13 malloc() 分配的是物理内存吗?
不是的,malloc() 分配的是虚拟内存。
如果分配后的虚拟内存没有被访问的话,是不会将虚拟内存不会映射到物理内存,这样就不会占用物理内存了。
只有在访问已分配的虚拟地址空间的时候,操作系统通过查找页表,发现虚拟内存对应的页没有在物理内存中,就会触发缺页中断,然后操作系统会建立虚拟内存和物理内存之间的映射关系
我们在上面的进程往下执行,看看通过 free() 函数释放内存后,堆内存还在吗?
这是因为与其把这 1 字节释放给操作系统,不如先缓存着放进 malloc 的内存池里,当进程再次申请 1 字节的内存时就可以直接复用,这样速度快了很多。
当然,当进程退出后,操作系统就会回收进程的所有资源。
上面说的 free 内存后堆内存还存在,是针对 malloc 通过 brk() 方式申请的内存的情况。
如果 malloc 通过 mmap 方式申请的内存,free 释放内存后就会归还给操作系统
malloc() 在分配内存的时候,并不是老老实实按用户预期申请的字节数来分配内存空间大小,而是会预分配更大的空间作为内存池。
3.14 为什么不全部使用 mmap 来分配内存?
因为向操作系统申请内存,是要通过系统调用的,执行系统调用是要进入内核态的,然后在回到用户态,运行态的切换会耗费不少时间。
所以,申请内存的操作应该避免频繁的系统调用,如果都用 mmap 来分配内存,等于每次都要执行系统调用。
另外,因为 mmap 分配的内存每次释放的时候,都会归还给操作系统,于是每次 mmap 分配的虚拟地址都是缺页状态的,然后在第一次访问该虚拟地址的时候,就会触发缺页中断。
频繁通过 mmap 分配的内存话,不仅每次都会发生运行态的切换,还会发生缺页中断(在第一次访问虚拟地址后),这样会导致 CPU 消耗较大。
为了改进这两个问题,malloc 通过 brk() 系统调用在堆空间申请内存的时候,由于堆空间是连续的,所以直接预分配更大的内存来作为内存池,当内存释放的时候,就缓存在内存池中。
等下次在申请内存的时候,就直接从内存池取出对应的内存块就行了,而且可能这个内存块的虚拟地址与物理地址的映射关系还存在,这样不仅减少了系统调用的次数,也减少了缺页中断的次数,这将大大降低 CPU 的消耗
但是如果下次申请的内存大于 30k,没有可用的空闲内存空间,必须向 OS 申请,实际使用内存继续增大。
因此,随着系统频繁地 malloc 和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致“内存泄露”。而这种“泄露”现象使用 valgrind 是无法检测出来的。
所以,malloc 实现中,充分考虑了 sbrk 和 mmap 行为上的差异及优缺点,默认分配大块内存 (128KB) 才使用 mmap 分配内存空间
3.15 free() 函数只传入一个内存地址,为什么能知道要释放多大的内存?
还记得,我前面提到, malloc 返回给用户态的内存起始地址比进程的堆空间起始地址多了 16 字节吗?
这个多出来的 16 字节就是保存了该内存块的描述信息,比如有该内存块的大小。

这样当执行 free() 函数时,free 会对传入进来的内存地址向左偏移 16 字节,然后从这个 16 字节的分析出当前的内存块的大小,自然就知道要释放多大的内存了。
4. 系统中断
在CPU执行程序的过程中,出现了某种紧急情况或异常的事件时,暂停正在执行的程序,转去处理该事件,并在处理完该事件之后返回断点处(指返回主程序时执行的第一条指令的地址)继续执行刚刚被暂停的程序。
软中断和硬中断
我们通常所说的中断指的是硬中断(hardirq)。
软中断是执行中断指令产生的,而硬中断是由外设引发的。
硬中断的中断号是由中断控制器提供的,软中断的中断号由指令直接指出,无需使用中断控制器。
硬中断是可屏蔽的,软中断不可屏蔽。
硬中断处理程序要确保它能快速地完成任务,这样程序执行时才不会等待较长时间,称为上半部。
软中断处理硬中断未完成的工作,是一种推后执行的机制,属于下半部。
4.1 中断的处理过程
中断请求
中断源向CPU发出中断请求,
- 发生在CPU内部的中断(内部中断),不需要中断请求,CPU内部的中断控制逻辑直接接收处理。
软中断是执行中断指令产生的, 而硬中断是由外设引发的,比如当网卡收到数据包的时候,就会发出一个中断。
- 外部中断请求由中断源提出。外部中断源利用CPU的中断输入引脚 输入中断请求信号。
一般CPU设有两个中断请求输入引脚:可屏蔽中断请求输入引脚和不可屏蔽中断请求输入引脚。
中断请求触发器
每个中断源发中断请求信号的时间是不确定的,而CPU在何时响应中断也是不确定的。
所以,每个中断源都有一个中断请求触发器,锁存自己的中断请求信号,并保持到CPU响应这个中断请求之后才将其清除。
在CPU内部有一个中断允许触发器,当其为“1”时,允许CPU响应中断, 称为开中断。
若其为“0”,不允许CPU响应中断,中断被屏蔽,称为关中断
中断响应
① 保护硬件现场(PC)和(PSW); 把CPU的状态保存在寄存器中。
程序计数器(Program Counter,PC)用来指出下一条指令在主存储器中的地址,
程序状态字(Program Status Word,PSW)用来表征当前运算的状态及程序的工作方式。
② 关中断;
中断服务处理阶段
1)保护现场。 在中断服务程序的起始部分安排若干条入栈指令,再将各寄存器的内容压入堆栈保存。
2)开中断。 在中断服务程序执行期间允许级别更高的中断请求中断现行的中断服务程序,实现中断嵌套。
3)中断服务。 获得中断服务程序的入口地址。完成中断源的具体要求,根据中断类型码在中断向量表中找到相应中断服务程序的入口地址。
4)恢复现场。 中断服务程序结束前,必须恢复主程序的中断现场。通常是将保存在堆栈中的现场信息弹出到原来的寄存器中。 返回到原程序的断点处,恢复硬件现场,继续执行原程序。
5)中断返回。 返回到原程序的断点处,恢复硬件现场,继续执行原程序。
4.2 中断和轮询有什么区别?
轮询:CPU对特定设备轮流询问。 中断:通过特定事件提醒CPU。
轮询:效率低等待时间长,CPU利用率不高。 中断:容易遗漏问题,CPU利用率不高。
CPU要和外设进行通信,可以采用轮询和中断两种方式。
因为轮询方式需要CPU轮询外设,查询外设是否发生中断,效率不高显而易见。于是增加了如下图的中断系统来减轻CPU负担,但是这样做效率就高了吗?
本质上,采用中断系统后,CPU仍然需要每隔一小段时间去查询中断控制寄存器TCON的各位状态,以判断是否有外设中断发生,否则CPU仍旧无法知道外设的当前状态。
如上所述,中断和轮询,好像又没啥区别,CPU仍旧摆脱不了查询的命运。
但是让CPU直接和各个外设逐一沟通,和让CPU只与中断控制系统机构沟通,效率是完全不一样的。
为了证明我的推断,我们假设, CPU外接20个不同的设备,这20个外设中在某一刻有两个外设同时中断,正好这个时候CPU来查看外设的状态,如果是轮询方式,CPU需要一一遍历20种不同的外设控制器,才能判断哪些外设刚才申请过中断,哪些外设没有申请中断。如果采用中断方式处理呢?
CPU只需查询一下中断标志位,处理最高优先级的那个中断,其他的事情全交给中断系统去处理,效率提高了20倍!
从中,我们也可以发现一个现象,不论硬件设计如何巧妙,软件产品如何复杂,在设计原则上仍然是在不断的做加法。

异常与中断不同,它在产生时必须考虑与处理器时钟同步。实际上,异常也称为同步中断。
比如,在处理器执行到由于编程失误而导致的错误指令的时候,或者在执行期间出现特殊情况(缺页),必须靠内核来处理的,处理器就产生一个异常。
和中断的的工作方式类似,其差异只在于中断是由硬件而不是软件引起的。
5. 磁盘空间
5.1 磁盘调度
- 先来先服务算法,先到来的请求,先被服务。
- 最短寻道时间优先算法,优先选择从当前磁头位置所需寻道时间最短的请求,还是以这个序列为例子:
- 扫描算法算法,最短寻道时间优先算法会产生饥饿的原因在于:磁头有可能再一个小区域内来回得移动。为了防止这个问题,可以规定:磁头在一个方向上移动,访问所有未完成的请求,直到磁头到达该方向上的最后的磁道,才调换方向,这就是扫描(Scan)算法
- 循环扫描算法,只有磁头朝某个特定方向移动时,才处理磁道访问请求,而返回时直接快速移动至最靠边缘的磁道,也就是复位磁头,这个过程是很快的,并且返回中途不处理任何请求,该算法的特点,就是磁道只响应一个方向上的请求。
- LOOK 与 C-LOOK 算法,那这其实是可以优化的,优化的思路就是磁头在移动到「最远的请求」位置,然后立即反向移动。
6. 文件系统
6.1 软硬链接
和普通文件不同的是,普通文件的块里面保存的是文件数据,而目录文件的块里面保存的是目录里面一项一项的文件信息。
有时候我们希望给某个文件取个别名,那么在 Linux 中可以通过硬链接(Hard Link) 和软链接(Symbolic Link) 的方式来实现,它们都是比较特殊的文件,但是实现方式也是不相同的。
硬链接是多个目录项中的「索引节点」指向一个文件,也就是指向同一个 inode,但是 inode 是不可能跨越文件系统的,每个文件系统都有各自的 inode 数据结构和列表,所以硬链接是不可用于跨文件系统的。
由于多个目录项都是指向一个 inode,那么只有删除文件的所有硬链接以及源文件时,系统才会彻底删除该文件。
软链接相当于重新创建一个文件,这个文件有独立的 inode,但是这个文件的内容是另外一个文件的路径,所以访问软链接的时候,实际上相当于访问到了另外一个文件,所以软链接是可以跨文件系统的,甚至目标文件被删除了,链接文件还是在的,只不过指向的文件找不到了而已。
6.2 直接io与非直接io
-
直接 I/O,不会发生内核缓存和用户程序之间数据复制,而是直接经过文件系统访问磁盘。
-
非直接 I/O,读操作时,数据从内核缓存中拷贝给用户程序,写操作时,数据从用户程序拷贝给内核缓存,再由内核决定什么时候写入数据到磁盘。
以下几种场景会触发内核缓存的数据写入磁盘:
- 在调用
write的最后,当发现内核缓存的数据太多的时候,内核会把数据写到磁盘上; - 用户主动调用
sync,内核缓存会刷到磁盘上; - 当内存十分紧张,无法再分配页面时,也会把内核缓存的数据刷到磁盘上;
- 内核缓存的数据的缓存时间超过某个时间时,也会把数据刷到磁盘上;
- 在调用
6.3. 同步IO,异步IO

在前⾯我们知道了,I/O 是分为两个过程的:
|
|
阻塞 I/O 会阻塞在「过程 1 」和「过程 2」,
⽽⾮阻塞 I/O 和基于⾮阻塞 I/O 的多路复⽤只会阻塞在「过程2」,所以这三个都可以认为是同步 I/O。
异步 I/O 则不同,「过程 1 」和「过程 2 」都不会阻塞。
-
食堂打饭例子
举个你去饭堂吃饭的例⼦,你好⽐⽤户程序,饭堂好⽐操作系统。
阻塞 I/O 好⽐,你去饭堂吃饭,但是饭堂的菜还没做好,然后你就⼀直在那⾥等啊等,等了好⻓⼀段时间 终于等到饭堂阿姨把菜端了出来(数据准备的过程),但是你还得继续等阿姨把菜(内核空间)打到你的饭盒⾥(⽤户空间),经历完这两个过程,你才可以离开。
⾮阻塞 I/O 好⽐,你去了饭堂,问阿姨菜做好了没有,阿姨告诉你没,你就离开了,过⼏⼗分钟,你**⼜来饭堂问阿姨,阿姨说做好了**,于是阿姨帮你把菜打到你的饭盒⾥,这个过程你是得等待的。基于⾮阻塞的 I/O 多路复⽤好⽐,你去饭堂吃饭,发现有⼀排窗⼝,饭堂阿姨告诉你这些窗⼝都还没做好菜,等做好了再通知你,于是等啊等( select 调⽤中),过了⼀会阿姨通知你菜做好了,但是不知道哪个窗⼝的菜做好了,你⾃⼰看吧。于是你只能⼀个⼀个窗⼝去确认,后⾯发现 5 号窗⼝菜做好了,于是你让 5 号窗⼝的阿姨帮你打菜到饭盒⾥,这个打菜的过程你是要等待的,虽然时间不⻓。打完菜后,你⾃然就可以离开了。
异步 I/O 好⽐,你让饭堂阿姨将菜做好并把菜打到饭盒⾥后,把饭盒送到你⾯前,整个过程你都不需要任何等待.
同步和异步IO的概念:
同步 是用户线程发起I/O请求后需要等待或者轮询内核I/O操作完成后才能继续执行
异步 是用户线程发起I/O请求后仍需要继续执行,当内核I/O操作完成后会通知用户线程,或者调用用户线程注册的回调函数。
阻塞和非阻塞IO的概念:
阻塞是指I/O操作需要彻底完成后才能返回用户空间。
非阻塞是指I/O操作被调用后立即返回一个状态值,无需等I/O操作彻底完成。
IO模型
这里统一使用Linux下的系统调用recv作为例子,它用于从套接字上接收一个消息,因为是一个系统调用,所以调用时会从用户进程空间切换到内核空间运行一段时间再切换回来。
默认情况下recv会等到网络数据到达并且复制到用户进程空间或者发生错误时返回,而第4个参数flags可以让它马上返回。
套接字(Socket),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。
一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制
- 阻塞IO模型
使用recv的默认参数,会一直等数据直到拷贝到用户空间,这段时间内进程始终阻塞。
A同学用杯子装水,打开水龙头装满水然后离开。这一过程就可以看成是使用了阻塞IO模型。
因为如果水龙头没有水,他也要等到有水 并 装满杯子才能离开去做别的事情。很显然,这种IO模型是同步的。

- 非阻塞IO模型
改变flags,让recv不管有没有获取到数据都返回,如果没有数据那么一段时间后再调用recv看看,如此循环。
B同学也用杯子装水,打开水龙头后发现没有水,它离开了,过一会他又拿着杯子来看看……在中间离开的这些时间里,B同学离开了装水现场(回到用户进程空间),可以做他自己的事情。这就是非阻塞IO模型。
但是它只有是检查有无数据的时候是非阻塞的,在数据到达的时候依然要等待复制数据到用户空间(等着水将水杯装满),因此它还是同步IO。

- IO复用模型
这里在调用recv前先调用select或者poll,这2个系统调用都可以在内核准备好数据(网络数据到达内核)时告知用户进程,这个时候再调用recv一定是有数据的。
因此这一过程中它是阻塞于select或poll,而没有阻塞于recv,有人将非阻塞IO定义成在读写操作时没有阻塞于系统调用的IO操作 (不包括数据从内核复制到用户空间时的阻塞,因为这相对于网络IO来说确实很短暂),如果按这样理解,这种IO模型也能称之为非阻塞IO模型,但是按POSIX来看,它也是同步IO,那么也和楼上一样称之为同步非阻塞IO吧。
这种IO模型比较特别,因为它能同时监听多个文件描述符(fd)
一旦某个文件描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作(这样就不需要每个用户进程不断的询问内核数据准备好了没)
这个时候C同学来装水,发现有一排水龙头,舍管阿姨告诉他这些水龙头都还没有水,等有水了告诉他。于是等啊等(select调用中),过了一会阿姨告诉他有水了,但不知道是哪个水龙头有水,自己看吧。于是C同学一个个打开,往杯子里装水(recv)。
这里再顺便说说鼎鼎大名的epoll(高性能的代名词啊),epoll也属于IO复用模型,
主要区别在于舍管阿姨会告诉C同学哪几个水龙头有水了,不需要一个个打开看(当然还有其它区别)。

- 信号驱动IO模型
通过调用sigaction注册信号函数,等内核数据准备好的时候系统中断当前程序,执行信号函数(在这里面调用recv)。
D同学让舍管阿姨等有水的时候通知他(注册信号函数),没多久D同学得知有水了,跑去装水。是不是很像异步IO?很遗憾,它还是同步IO(省不了装水的时间啊)。

- 异步IO模型
调用aio_read,让内核等数据准备好,并且复制到用户进程空间后执行事先指定好的函数。
E同学让舍管阿姨将杯子装满水后通知他。整个过程E同学都可以做别的事情(没有recv),这才是真正的异步IO。

最后,总结比较下五种IO模型:

总结
IO分两阶段:
|
|
一般来讲:阻塞IO模型、非阻塞IO模型、IO复用模型(select/poll/epoll)、信号驱动IO模型都属于同步IO,因为阶段2是阻塞的(尽管时间很短)。
只有异步IO模型是符合POSIX异步IO操作含义的,不管在阶段1还是阶段2都可以干别的事。
IO分两阶段(一旦拿到数据后就变成了数据操作,不再是IO):
1.数据准备阶段
2.内核空间复制数据到用户进程缓冲区(用户空间)阶段
同步是用户线程发起I/O请求后需要等待或者轮询内核I/O操作完成后才能继续执行
异步是用户线程发起I/O请求后仍需要继续执行,当内核I/O操作完成后会通知用户线程,或者调用用户线程注册的回调函数
在操作系统中,程序运行的空间分为内核空间和用户空间。
应用程序都是运行在用户空间的,所以它们能操作的数据也都在用户空间。
阻塞IO和非阻塞IO的区别在于第一步发起IO请求是否会被阻塞: 如果阻塞直到完成那么就是传统的阻塞IO,如果不阻塞,那么就是非阻塞IO。
同步IO和异步IO的区别就在于第二个步骤是否阻塞: 如果不阻塞,而是操作系统帮你做完IO操作再将结果返回给你,否则就是异步IO
异步io的实现
来看一下基本的异步读的操作流程,我们假定发起任务的时候运行是由主线程启动的,那么:
- 注册者申请一个异步读任务,同时将自身的一个回调注册给异步读管理器。调用者在Dispose时,必须也将自身从异步读管理器中注销。(主线程)
- 管理器在收到任务后,将相关数据封包,并启动一个新的线程(或者从线程池提取一个线程)来执行异步读任务。(主线程)
- 在子线程异步读完毕后,通知管理器提取数据。(子线程)
- 管理器处理封包,并通过调用注册者的回调来将数据重新推送下去(子线程)
可以看到,在这个过程中,发起任务和处理任务分别是在主线程和子线程进行的,所以管理器自身必须有相应的同步机制来保证在不同线程上可以正确的运行。
我们还需要考虑一个注册者同时发起多个读取任务的可能性。所以需要一定的机制来保证正确的区分这些任务。
在这里,我们可以选用一个较为简单的方法,即为每个注册者开辟一个单独的std::vector<>,并将这些任务按顺序放在vector中,同时将每个任务对应的下标返回给注册者。这样当读任务完成时,我们可以通过下标来告知注册者是哪个任务完成了。
读取缓冲区的分配与释放应该统一由管理器负责,而不是注册者。
因此注册者只能拿到一个const状态的缓冲区,如果需要使用内容则需要将其复制到自有的缓冲区。
6.4 如何服务更多的用户
相信你知道 TCP 连接是由四元组唯一确认的,这个四元组就是:本机IP, 本机端口, 对端IP, 对端端口。
服务器作为服务方,通常会在本地固定监听一个端口,等待客户端的连接。因此服务器的本地 IP 和端口是固定的,于是对于服务端 TCP 连接的四元组只有对端 IP 和端口是会变化的,所以最大 TCP 连接数 = 客户端 IP 数×客户端端口数。
对于 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数约为 2 的 48 次方。
这个理论值相当“丰满”,但是服务器肯定承载不了那么大的连接数,主要会受两个方面的限制:
- 文件描述符,Socket 实际上是一个文件,也就会对应一个文件描述符。
- 在 Linux 下,单个进程打开的文件描述符数是有限制的,没有经过修改的值一般都是 1024,不过我们可以通过 ulimit 增大文件描述符的数目;
- 系统内存,每个 TCP 连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的;
那如果服务器的内存只有 2 GB,网卡是千兆的,能支持并发 1 万请求吗?
并发 1 万请求,也就是经典的 C10K 问题 ,C 是 Client 单词首字母缩写,C10K 就是单机同时处理 1 万个请求的问题。
从硬件资源角度看,对于 2GB 内存千兆网卡的服务器,如果每个请求处理占用不到 200KB 的内存和 100Kbit 的网络带宽就可以满足并发 1 万个请求。
不过,要想真正实现 C10K 的服务器,要考虑的地方在于服务器的网络 I/O 模型,效率低的模型,会加重系统开销,从而会离 C10K 的目标越来越远。
基于最原始的阻塞网络 I/O, 如果服务器要支持多个客户端,其中比较传统的方式,就是使用多进程模型,也就是为每个客户端分配一个进程来处理请求。
服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept() 函数就会返回一个「已连接 Socket」,这时就通过 fork() 函数创建一个子进程,实际上就把父进程所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。
这两个进程刚复制完的时候,几乎一摸一样。
不过,会根据返回值来区分是父进程还是子进程,如果返回值是 0,则是子进程;如果返回值是其他的整数,就是父进程。
正因为子进程会复制父进程的文件描述符,于是就可以直接使用「已连接 Socket 」和客户端通信了,可以发现,子进程不需要关心「监听 Socket」,只需要关心「已连接 Socket」;父进程则相反,将客户服务交给子进程来处理,因此父进程不需要关心「已连接 Socket」,只需要关心「监听 Socket」。
下面这张图描述了从连接请求到连接建立,父进程创建生子进程为客户服务。
另外,当「子进程」退出时,实际上内核里还会保留该进程的一些信息,也是会占用内存的,如果不做好“回收”工作,就会变成僵尸进程,随着僵尸进程越多,会慢慢耗尽我们的系统资源。
因此,父进程要“善后”好自己的孩子,怎么善后呢?那么有两种方式可以在子进程退出后回收资源,分别是调用 wait() 和 waitpid() 函数。
这种用多个进程来应付多个客户端的方式,在应对 100 个客户端还是可行的,但是当客户端数量高达一万时,肯定扛不住的,因为每产生一个进程,必会占据一定的系统资源,而且进程间上下文切换的“包袱”是很重的,性能会大打折扣。
进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
那么,我们可以使用线程池的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程,这样当由新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出已连接 Socket 进程处理。
一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,
把时间拉长来看,多个请求复用了一个进程,这就是多路复用,这种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用。
我们熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。
select/poll/epoll 是如何获取网络事件的呢?
在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。
select/poll/epoll 这是三个多路复用接口,都能实现 C10K 吗?接下来,分别说说它们。
6.5 select,poll,epoll的原理、区别
I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。
select,poll,epoll都是IO多路复用的机制。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的。
而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间
select 的核心功能是调用tcp文件系统的poll函数,不停的查询,如果没有想要的数据,主动执行一次调度(防止一直占用cpu),直到有一个连接有想要的消息为止。从这里可以看出select的执行方式基本就是不同的调用poll,直到有需要的消息为止。
select:
将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,
当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。
所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符
缺点:
1、每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大;
2、同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大;
3、select支持的文件描述符数量太小了,默认是1024。
优点:
1、select的可移植性更好,在某些Unix系统上不支持poll()。
2、select对于超时值提供了更好的精度:微秒,而poll是毫秒。
Poll
poll本质上和select没有区别,poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。
它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
缺点:
1、大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义;
2、与select一样,poll返回后,需要轮询pollfd来获取就绪的描述符。
优点:
1、poll() 不要求开发者计算最大文件描述符加一的大小。
2、poll() 在应付大数目的文件描述符的时候速度更快,相比于select。
3、它没有最大连接数的限制,原因是它是基于链表来存储的。
但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。
epoll
epoll同样只告知那些就绪的文件描述符,而且当我们调用epoll_wait()获得就绪文件描述符时, 返回的不是实际的描述符,而是一个代表就绪描述符数量的值,你只需要去epoll指定的一个数组中依次取得相应数量的文件描述符即可.
这里也使用了内存映射技术,这样便彻底省掉了这些文件描述符在系统调用时复制的开销。
-
epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过
epoll_ctl()函数加入内核中的红黑树里(红黑树是个高效的数据结构,增删查一般时间复杂度是O(logn))通过对这棵黑红树进行操作,这样就不需要像 select/poll 每次操作时都传入整个 socket 集合,只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
-
epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用
epoll_wait()函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
epoll 被称为解决 C10K 问题的利器。
epoll的优点就是改进了前面所说缺点:
-
支持一个进程打开大数目的socket描述符:
相比select**,epoll则没有对FD的限制,它所支持的FD上限是最大可以打开文件的数目**,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。
-
IO效率不随FD数目增加而线性下降:epoll不存在这个问题,它只会对“活跃”的socket进行操作— 这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。
那么,只有“活跃”的socket才会主动的去调用 callback函数,其他idle状态socket则不会,在这点上,epoll实现了一个“伪”AIO,因为这时候推动力在os内核。
在一些 benchmark中,如果所有的socket基本上都是活跃的—比如一个高速LAN环境,epoll并不比select/poll有什么效率,相反,如果过多使用epoll_ctl,效率相比还有稍微的下降。但是一旦使用idle connections模拟WAN环境,epoll的效率就远在select/poll之上了。
-
使用mmap加速内核与用户空间的消息传递:这点实际上涉及到epoll的具体实现了。
无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就 很重要,在这点上,epoll是通过内核于用户空间mmap同一块内存实现的。
执行epoll_ create时,创建了红黑树和就绪链表;
执行epoll_ ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上。
然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行epoll_wait时立刻返回准备就绪链表里的数据即可。
(1) select==>时间复杂度O(n)
它仅仅知道了,有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。
(2) poll==>时间复杂度O(n)
poll本质上和select没有区别,它将用户传入的链式数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的.
(3) epoll==>时间复杂度O(1)
epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))
水平触发(level-triggered,也被称为条件触发)LT: 只要满足条件,就触发一个事件 (只要有数据没有被获取,内核就不断通知你)
边缘触发(edge-triggered)ET: 每当状态变化时,触发一个事件。
epoll 支持边缘触发和水平触发的方式,而 select/poll 只支持水平触发,
一般而言,边缘触发的方式会比水平触发的效率高。
6.6 DMA和零拷贝
可以看到,整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。
简单的搬运几个字符数据那没问题,但是如果我们用千兆网卡或者硬盘传输大量数据的时候,都用 CPU 来搬运的话,肯定忙不过来。
计算机科学家们发现了事情的严重性后,于是就发明了 DMA 技术,也就是直接内存访问(Direct Memory Access) 技术。
什么是 DMA 技术?简单理解就是,在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。
- 用户进程调用 read 方法,向操作系统发出 I/O 请求,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态;
- 操作系统收到请求后,进一步将 I/O 请求发送 DMA,然后让 CPU 执行其他任务;
- DMA 进一步将 I/O 请求发送给磁盘;
- 磁盘收到 DMA 的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知自己缓冲区已满;
- DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务;
- 当 DMA 读取了足够多的数据,就会发送中断信号给 CPU;
- CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回;
可以看到, 整个数据传输的过程,CPU 不再参与数据搬运的工作,而是全程由 DMA 完成,
但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。
早期 DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。
传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。
- 第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
- 第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
- 第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。
- 第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。

如何实现零拷贝?
零拷贝技术实现的方式通常有 2 种:
- mmap + write
- sendfile
下面就谈一谈,它们是如何减少「上下文切换」和「数据拷贝」的次数。
mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。
- 应用进程调用了
mmap()后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区; - 应用进程再调用
write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据; - 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的
我们可以得知,通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。
但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次
你可以在你的 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性:
|
|
于是,从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,具体过程如下:
- 第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
- 第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;
- 所以,这个过程之中,只进行了 2 次数据拷贝
这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。
所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。
零拷贝技术是基于 PageCache 的,PageCache 会缓存最近访问的数据,提升了访问缓存数据的性能。
同时,为了解决机械硬盘寻址慢的问题,它还协助 I/O 调度算法实现了 IO 合并与预读,这也是顺序读比随机读性能好的原因。这些优势,进一步提升了零拷贝的性能。PageCache 使用了「预读功能」。
比如,假设 read 方法每次只会读 32 KB 的字节,虽然 read 刚开始只会读 0 ~ 32 KB 的字节,但内核会把其后面的 32~64 KB 也读取到 PageCache,这样后面读取 32~64 KB 的成本就很低,如果在 32~64 KB 淘汰出 PageCache 前,进程读取到它了,收益就非常高。
事实上,Kafka 这个开源项目,就利用了「零拷贝」技术,从而大幅提升了 I/O 的吞吐率,这也是 Kafka 在处理海量数据为什么这么快的原因之一。
另外,Nginx 也支持零拷贝技术,一般默认是开启零拷贝技术,这样有利于提高文件传输的效率,是否开启零拷贝技术的配置如下:

6.7 大文件传输
- 应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗。在 MySQL 数据库中,可以通过参数设置开启直接 I/O,默认是不开启;
- 传输大文件的时候,由于大文件难以命中 PageCache 缓存,而且会占满 PageCache 导致「热点」文件无法充分利用缓存,从而增大了性能开销,因此,这时应该使用直接 I/O。
另外,由于直接 I/O 绕过了 PageCache,就无法享受内核的这两点的优化:
- 内核的 I/O 调度算法会缓存尽可能多的 I/O 请求在 PageCache 中,最后「合并」成一个更大的 I/O 请求再发给磁盘,这样做是为了减少磁盘的寻址操作;
- 内核也会「预读」后续的 I/O 请求放在 PageCache 中,一样是为了减少对磁盘的操作;
于是,**传输大文件的时候,使用「异步 I/O + 直接 I/O」**了,就可以无阻塞地读取文件了。
所以,传输文件的时候,我们要根据文件的大小来使用不同的方式:
- 传输大文件的时候,使用「异步 I/O + 直接 I/O」;
- 传输小文件的时候,则使用「零拷贝技术」;
另外,当传输大文件时,不能使用零拷贝,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,并且大文件的缓存命中率不高,这时就需要使用「异步 IO + 直接 IO 」的方式。
在 Nginx 里,可以通过配置,设定一个文件大小阈值,针对大文件使用异步 IO 和直接 IO,而对小文件使用零拷贝。
6.8 Socket模型
要想客户端和服务器能在网络中通信,那必须得使用 Socket 编程,它是进程间通信里比较特别的方式,特别之处在于它是可以跨主机间通信。
- 服务端和客户端初始化
socket,得到文件描述符; - 服务端调用
bind,将绑定在 IP 地址和端口; - 服务端调用
listen,进行监听; - 服务端调用
accept,等待客户端连接; - 客户端调用
connect,向服务器端的地址和端口发起连接请求; - 服务端
accept返回用于传输的socket的文件描述符; - 客户端调用
write写入数据;服务端调用read读取数据; - 客户端断开连接时,会调用
close,那么服务端read读取数据的时候,就会读取到了EOF,待处理完数据后,服务端调用close,表示连接关闭。
创建 Socket 的时候,可以指定网络层使用的是 IPv4 还是 IPv6,传输层使用的是 TCP 还是 UDP。
主线程:
-
创建完成端口对象
-
创建工作者线程(这里工作者线程的数量是按照CPU核的个数来决定,这样可以达到最佳性能)
-
创建监听套接字,绑定,监听,然后程序进入循环
-
在循环中,做了以下几件事情:
(1) 接受一个客户端连接
(2) 将该客户端套接字与完成端口绑定到一起(还是调用CreateIoCompletionPort,但这次的作用不同)。
注意,按道理来讲,此时传递给CreateIoCompletionPort的第三个参数应该是一个完成键,一般来讲,**程序都是传递一个单句柄数据结构的地址,**该单句柄数据包含了和该客户端连接有关的信息,由于我们只关心套接字句柄,所以直接将套接字句柄作为完成键传递;
(3) 触发一个WSARecv异步调用,这次又用到了“尾随数据”,使接收数据所用的缓冲区紧跟在WSAOVERLAPPED对象之后,此外,还有操作 类型等重要信息。
7. 设备管理
我们的电脑设备可以接非常多的输入输出设备,比如键盘、鼠标、显示器、网卡、硬盘、打印机、音响等等,每个设备的用法和功能都不同,那操作系统是如何把这些输入输出设备统一管理的呢?
为了屏蔽设备之间的差异,每个设备都有一个叫设备控制器(Device Control) 的组件,比如硬盘有硬盘控制器、显示器有视频控制器等。
- 数据寄存器,CPU 向 I/O 设备写入需要传输的数据,比如要打印的内容是「Hello」,CPU 就要先发送一个 H 字符给到对应的 I/O 设备。
- 命令寄存器,CPU 发送一个命令,告诉 I/O 设备,要进行输入/输出操作,于是就会交给 I/O 设备去工作,任务完成后,会把状态寄存器里面的状态标记为完成。
- 状态寄存器,目的是告诉 CPU ,现在已经在工作或工作已经完成,如果已经在工作状态,CPU 再发送数据或者命令过来,都是没有用的,直到前面的工作已经完成,状态寄存标记成已完成,CPU 才能发送下一个字符和命令。
7.1 键盘敲入字母时,期间发生了什么?
那当用户输入了键盘字符,键盘控制器就会产生扫描码数据,并将其缓冲在键盘控制器的寄存器中,紧接着键盘控制器通过总线给 CPU 发送中断请求。
CPU 收到中断请求后,操作系统会保存被中断进程的 CPU 上下文,然后调用键盘的中断处理程序。
键盘的中断处理程序是在键盘驱动程序初始化时注册的,那键盘中断处理函数的功能就是从键盘控制器的寄存器的缓冲区读取扫描码,再根据扫描码找到用户在键盘输入的字符,**如果输入的字符是显示字符,那就会把扫描码翻译成对应显示字符的 ASCII 码(**比如用户在键盘输入的是字母 A,是显示字符,于是就会把扫描码翻译成 A 字符的 ASCII 码)
得到了显示字符的 ASCII 码后,就会把 ASCII 码放到「读缓冲区队列」,接下来就是要把显示字符在屏幕上了,显示设备的驱动程序会定时从「读缓冲区队列」读取数据放到「写缓冲区队列」,最后把「写缓冲区队列」的数据一个一个写入到显示设备的控制器的寄存器中的数据缓冲区,最后将这些数据显示在屏幕里。
显示出结果后,恢复被中断进程的上下文。