并发编程导论

并发编程导论是对于分布式计算-并发编程 https://url.wx-coder.cn/Yagu8 系列的总结与概括。java

并发编程导论

随着硬件性能的迅猛发展与大数据时代的来临,并发编程日益成为编程中不可忽略的重要组成部分。简单定义来看,若是执行单元的逻辑控制流在时间上重叠,那它们就是并发(Concurrent)的。并发编程复兴的主要驱动力来自于所谓的“多核危机”。正如摩尔定律所预言的那样,芯片性能仍在不断提升,但相比加快 CPU 的速度,计算机正在向多核化方向发展。正如 Herb Sutter 所说,“免费午饭的时代已然终结”。为了让代码运行得更快,单纯依靠更快的硬件已没法知足要求,并行和分布式计算是现代应用程序的主要内容,咱们须要利用多个核心或多台机器来加速应用程序或大规模运行它们。node

并发编程是很是普遍的概念,其向下依赖于操做系统、存储等,与分布式系统、微服务等,而又会具体落地于 Java 并发编程、Go 并发编程、JavaScript 异步编程等领域。云计算承诺在全部维度上(内存、计算、存储等)实现无限的可扩展性,并发编程及其相关理论也是咱们构建大规模分布式应用的基础。git

本节主要讨论并发编程理论相关的内容,能够参阅 [Java 并发编程 https://url.wx-coder.cn/72vCj Go 并发编程 https://url.wx-coder.cn/FO9EX 等了解具体编程语言中并发编程的实践,能够参阅微服务实战 https://url.wx-coder.cn/7KZ2i 或者关系型数据库理论 https://url.wx-coder.cn/DJNQn 了解并发编程在实际系统中的应用。程序员

并发与并行

并发就是可同时发起执行的程序,指程序的逻辑结构;并行就是能够在支持并行的硬件上执行的并发程序,指程序的运⾏状态。换句话说,并发程序表明了全部能够实现并发行为的程序,这是一个比较宽泛的概念,并行程序也只是他的一个子集。并发是并⾏的必要条件;但并发不是并⾏的充分条件。并发只是更符合现实问题本质的表达,目的是简化代码逻辑,⽽不是使程序运⾏更快。要是程序运⾏更快必是并发程序加多核并⾏。github

简言之,并发是同一时间应对(dealing with)多件事情的能力;并行是同一时间动手作(doing)多件事情的能力。算法

image.png

并发是问题域中的概念——程序须要被设计成可以处理多个同时(或者几乎同时)发生的事件;一个并发程序含有多个逻辑上的独立执行块,它们能够独立地并行执行,也能够串行执行。而并行则是方法域中的概念——经过将问题中的多个部分并行执行,来加速解决问题。一个并行程序解决问题的速度每每比一个串行程序快得多,由于其能够同时执行整个任务的多个部分。并行程序可能有多个独立执行块,也可能仅有一个。数据库

具体而言,Redis 会是一个很好地区分并发和并行的例子。Redis 自己是一个单线程的数据库,可是能够经过多路复用与事件循环的方式来提供并发地 IO 服务。这是由于多核并行本质上会有很大的一个同步的代价,特别是在锁或者信号量的状况下。所以,Redis 利用了单线程的事件循环来保证一系列的原子操做,从而保证了即便在高并发的状况下也能达到几乎零消耗的同步。再引用下 Rob Pike 的描述:编程

A single-threaded program can definitely provides concurrency at the IO level by using an IO (de)multiplexing mechanism and an event loop (which is what Redis does).windows

线程级并发

从 20 世纪 60 年代初期出现时间共享以来,计算机系统中就开始有了对并发执行的支持;传统意义上,这种并发执行只是模拟出来的,是经过使一台计算机在它正在执行的进程间快速切换的方式实现的,这种配置称为单处理器系统。从 20 世纪 80 年代开始,多处理器系统,即由单操做系统内核控制的多处理器组成的系统采用了多核处理器与超线程(HyperThreading)等技术容许咱们实现真正的并行。多核处理器是将多个 CPU 集成到一个集成电路芯片上:数组

image

超线程,有时称为同时多线程(simultaneous multi-threading),是一项容许一个 CPU 执行多个控制流的技术。它涉及 CPU 某些硬件有多个备份,好比程序计数器和寄存器文件;而其余的硬件部分只有一份,好比执行浮点算术运算的单元。常规的处理器须要大约 20 000 个时钟周期作不一样线程间的转换,而超线程的处理器能够在单个周期的基础上决定要执行哪个线程。这使得 CPU 可以更好地利用它的处理资源。例如,假设一个线程必须等到某些数据被装载到高速缓存中,那 CPU 就能够继续去执行另外一个线程。

指令级并发

在较低的抽象层次上,现代处理器能够同时执行多条指令的属性称为指令级并行。实每条指令从开始到结束须要长得多的时间,大约 20 个或者更多的周期,可是处理器使用了很是多的聪明技巧来同时处理多达 100 条的指令。在流水线中,将执行一条指令所须要的活动划分红不一样的步骤,将处理器的硬件组织成一系列的阶段,每一个阶段执行一个步骤。这些阶段能够并行地操做,用来处理不一样指令的不一样部分。咱们会看到一个至关简单的硬件设计,它可以达到接近于一个时钟周期一条指令的执行速率。若是处理器能够达到比一个周期一条指令更快的执行速率,就称之为超标量(Super Scalar)处理器。

单指令、多数据

在最低层次上,许多现代处理器拥有特殊的硬件,容许一条指令产生多个能够并行执行的操做,这种方式称为单指令、多数据,即 SIMD 并行。例如,较新的 Intel 和 AMD 处理器都具备并行地对 4 对单精度浮点数(C 数据类型 float)作加法的指令。


内存模型

如前文所述,现代计算机一般有两个或者更多的 CPU,一些 CPU 还有多个核;其容许多个线程同时运行,每一个 CPU 在某个时间片内运行其中的一个线程。在存储管理 https://parg.co/Z47 一节中咱们介绍了计算机系统中的不一样的存储类别:

image

每一个 CPU 包含多个寄存器,这些寄存器本质上就是 CPU 内存;CPU 在寄存器中执行操做的速度会比在主内存中操做快很是多。每一个 CPU 可能还拥有 CPU 缓存层,CPU 访问缓存层的速度比访问主内存块不少,可是却比访问寄存器要慢。计算机还包括主内存(RAM),全部的 CPU 均可以访问这个主内存,主内存通常都比 CPU 缓存大不少,但速度要比 CPU 缓存慢。当一个 CPU 须要访问主内存的时候,会把主内存中的部分数据读取到 CPU 缓存,甚至进一步把缓存中的部分数据读取到内部的寄存器,而后对其进行操做。当 CPU 须要向主内存写数据的时候,会将寄存器中的数据写入缓存,某些时候会将数据从缓存刷入主内存。不管从缓存读仍是写数据,都没有必要一次性所有读出或者写入,而是仅对部分数据进行操做。

并发编程中的问题,每每源于缓存致使的可见性问题、线程切换致使的原子性问题以及编译优化带来的有序性问题。以 Java 虚拟机为例,每一个线程都拥有一个属于本身的线程栈(调用栈),随着线程代码的执行,调用栈会随之改变。线程栈中包含每一个正在执行的方法的局部变量。每一个线程只能访问属于本身的栈。调用栈中的局部变量,只有建立这个栈的线程才能够访问,其余线程都不能访问。即便两个线程在执行一段相同的代码,这两个线程也会在属于各自的线程栈中建立局部变量。所以,每一个线程拥有属于本身的局部变量。全部基本类型的局部变量所有存放在线程栈中,对其余线程不可见。一个线程能够把基本类型拷贝到其余线程,可是不能共享给其余线程,而不管哪一个线程建立的对象都存放在堆中。

可见性

所谓的可见性,便是一个线程对共享变量的修改,另一个线程可以马上看到。单核时代,全部的线程都是直接操做单个 CPU 的数据,某个线程对缓存的写对另一个线程来讲必定是可见的;譬以下图中,若是线程 B 在线程 A 更新了变量值以后进行访问,那么得到的确定是变量 V 的最新值。多核时代,每颗 CPU 都有本身的缓存,共享变量存储在主内存。运行在某个 CPU 中的线程将共享变量读取到本身的 CPU 缓存。在 CPU 缓存中,修改了共享对象的值,因为 CPU 并未将缓存中的数据刷回主内存,致使对共享变量的修改对于在另外一个 CPU 中运行的线程而言是不可见的。这样每一个线程都会拥有一份属于本身的共享变量的拷贝,分别存于各自对应的 CPU 缓存中。

可见性问题最经典的案例便是并发加操做,以下两个线程同时在更新变量 test 的 count 属性域的值,第一次都会将 count=0 读到各自的 CPU 缓存里,执行完 count+=1 以后,各自 CPU 缓存里的值都是 1,同时写入内存后,咱们会发现内存中是 1,而不是咱们指望的 2。以后因为各自的 CPU 缓存里都有了 count 的值,两个线程都是基于 CPU 缓存里的 count 值来计算,因此致使最终 count 的值都是小于 20000 的。

Thread th1 = new Thread(()->{
    test.add10K();
});

Thread th2 = new Thread(()->{
    test.add10K();
});

// 每一个线程中对相同对象执行加操做
count += 1;
复制代码

在 Java 中,若是多个线程共享一个对象,而且没有合理的使用 volatile 声明和线程同步,一个线程更新共享对象后,另外一个线程可能没法取到对象的最新值。当一个共享变量被 volatile 修饰时,它会保证修改的值会当即被更新到主存,当有其余线程须要读取时,它会去内存中读取新值。经过 synchronized 和 Lock 也可以保证可见性,synchronized 和 Lock 能保证同一时刻只有一个线程获取锁而后执行同步代码,而且在释放锁以前会将对变量的修改刷新到主存当中。所以能够保证可见性。

原子性

所谓的原子性,就是一个或者多个操做在 CPU 执行的过程当中不被中断的特性,CPU 能保证的原子操做是 CPU 指令级别的,而不是高级语言的操做符。咱们在编程语言中部分看似原子操做的指令,在被编译到汇编以后每每会变成多个操做:

i++

# 编译成汇编以后就是:
# 读取当前变量 i 并把它赋值给一个临时寄存器;
movl i(%rip), %eax
# 给临时寄存器+1;
addl $1, %eax
# 把 eax 的新值写回内存
movl %eax, i(%rip)
复制代码

咱们能够清楚看到 C 代码只须要一句,但编译成汇编却须要三步(这里不考虑编译器优化,实际上经过编译器优化能够将这三条汇编指令合并成一条)。也就是说,只有简单的读取、赋值(并且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操做)才是原子操做。按照原子操做解决同步问题方式:依靠处理器原语支持把上述三条指令合三为一,当作一条指令来执行,保证在执行过程当中不会被打断而且多线程并发也不会受到干扰。这样同步问题迎刃而解,这也就是所谓的原子操做。但处理器没有义务为任意代码片断提供原子性操做,尤为是咱们的临界区资源十分庞大甚至大小不肯定,处理器没有必要或是很难提供原子性支持,此时每每须要依赖于锁来保证原子性。

对应原子操做/事务在 Java 中,对基本数据类型的变量的读取和赋值操做是原子性操做,即这些操做是不可被中断的,要么执行,要么不执行。Java 内存模型只保证了基本读取和赋值是原子性操做,若是要实现更大范围操做的原子性,能够经过 synchronized 和 Lock 来实现。因为 synchronized 和 Lock 可以保证任一时刻只有一个线程执行该代码块,那么天然就不存在原子性问题了,从而保证了原子性。

有序性

顾名思义,有序性指的是程序按照代码的前后顺序执行。代码重排是指编译器对用户代码进行优化以提升代码的执行效率,优化前提是不改变代码的结果,即优化先后代码执行结果必须相同。

譬如:

int a = 1, b = 2, c = 3;
void test() {
    a = b + 1;
    b = c + 1;
    c = a + b;
}
复制代码

在 gcc 下的汇编代码 test 函数体代码以下,其中编译参数: -O0

movl b(%rip), %eax
addl $1, %eax
movl %eax, a(%rip)
movl c(%rip), %eax
addl $1, %eax
movl %eax, b(%rip)
movl a(%rip), %edx
movl b(%rip), %eax
addl %edx, %eax
movl %eax, c(%rip)
复制代码

编译参数:-O3

movl b(%rip), %eax                  ;将b读入eax寄存器
leal 1(%rax), %edx                  ;将b+1写入edx寄存器
movl c(%rip), %eax                  ;将c读入eax
movl %edx, a(%rip)                  ;将edx写入a
addl $1, %eax                       ;将eax+1
movl %eax, b(%rip)                  ;将eax写入b
addl %edx, %eax                     ;将eax+edx
movl %eax, c(%rip)                  ;将eax写入c
复制代码

在 Java 中与有序性相关的经典问题就是单例模式,譬如咱们会采用静态函数来获取某个对象的实例,而且使用 synchronized 加锁来保证只有单线程可以触发建立,其余线程则是直接获取到实例对象。

if (instance == null) {
    synchronized(Singleton.class) {
    if (instance == null)
        instance = new Singleton();
    }
}
复制代码

不过虽然咱们指望的对象建立的过程是:内存分配、初始化对象、将对象引用赋值给成员变量,可是实际状况下通过优化的代码每每会首先进行变量赋值,然后进行对象初始化。假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时刚好发生了线程切换,切换到了线程 B 上;若是此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null,因此直接返回 instance,而此时的 instance 是没有初始化过的,若是咱们这个时候访问 instance 的成员变量就可能触发空指针异常。

内存屏障

多处理器同时访问共享主存,每一个处理器都要对读写进行从新排序,一旦数据更新,就须要同步更新到主存上 (这里并不要求处理器缓存更新以后马上更新主存)。在这种状况下,代码和指令重排,再加上缓存延迟指令结果输出致使共享变量被修改的顺序发生了变化,使得程序的行为变得没法预测。为了解决这种不可预测的行为,处理器提供一组机器指令来确保指令的顺序要求,它告诉处理器在继续执行前提交全部还没有处理的载入和存储指令。一样的也能够要求编译器不要对给定点以及周围指令序列进行重排。这些确保顺序的指令称为内存屏障。具体的确保措施在程序语言级别的体现就是内存模型的定义。

POSIX、C++、Java 都有各自的共享内存模型,实现上并无什么差别,只是在一些细节上稍有不一样。这里所说的内存模型并不是是指内存布 局,特指内存、Cache、CPU、写缓冲区、寄存器以及其余的硬件和编译器优化的交互时对读写指令操做提供保护手段以确保读写序。将这些繁杂因素能够笼 统的概括为两个方面:重排和缓存,即上文所说的代码重排、指令重排和 CPU Cache。简单的说内存屏障作了两件事情:拒绝重排,更新缓存

C++11 提供一组用户 API std::memory_order 来指导处理器读写顺序。Java 使用 happens-before 规则来屏蔽具体细节保证,指导 JVM 在指令生成的过程当中穿插屏障指令。内存屏障也能够在编译期间指示对指令或者包括周围指令序列不进行优化,称之为编译器屏障,至关于轻量级内存屏障,它的工做一样重要,由于它在编译期指导编译器优化。屏障的实现稍微复杂一些,咱们使用一组抽象的假想指令来描述内存屏障的工做原理。使用 MB_R、MB_W、MB 来抽象处理器指令为宏:

  • MB_R 表明读内存屏障,它保证读取操做不会重排到该指令调用以后。
  • MB_W 表明写内存屏障,它保证写入操做不会重排到该指令调用以后。
  • MB 表明读写内存屏障,可保证以前的指令不会重排到该指令调用以后。

这些屏障指令在单核处理器上一样有效,由于单处理器虽不涉及多处理器间数据同步问题,但指令重排和缓存仍然影响数据的正确同步。指令重排是很是底层的且实 现效果差别很是大,尤为是不一样体系架构对内存屏障的支持程度,甚至在不支持指令重排的体系架构中根本没必要使用屏障指令。具体如何使用这些屏障指令是支持的 平台、编译器或虚拟机要实现的,咱们只须要使用这些实现的 API(指的是各类并发关键字、锁、以及重入性等,下节详细介绍)。这里的目的只是为了帮助更好 的理解内存屏障的工做原理。

内存屏障的意义重大,是确保正确并发的关键。经过正确的设置内存屏障能够确保指令按照咱们指望的顺序执行。这里须要注意的是内存屏蔽只应该做用于须要同步的指令或者还能够包含周围指令的片断。若是用来同步全部指令,目前绝大多数处理器架构的设计就会毫无心义。

Java 内存模型(Java Memory Model, JMM)

Java 内存模型着眼于描述 Java 中的线程是如何与内存进行交互,以及单线程中代码执行的顺序等,并提供了一系列基础的并发语义原则;最先的 Java 内存模型于 1995 年提出,致力于解决不一样处理器/操做系统中线程交互/同步的问题,规定和指引 Java 程序在不一样的内存架构、CPU 和操做系统间有肯定性地行为。在 Java 5 版本以前,JMM 并不完善,彼时多线程每每会在共享内存中读取到不少奇怪的数据;譬如,某个线程没法看到其余线程对共享变量写入的值,或者由于指令重排序的问题,某个线程可能看到其余线程奇怪的操做步骤。

Java 内存模型具有一些先天的“有序性”,即不须要经过任何手段就可以获得保证的有序性,这个一般也称为 happens-before 原则。若是两个操做的执行次序没法从 happens-before 原则推导出来,那么它们就不能保证它们的有序性,虚拟机能够随意地对它们进行重排序。

Java 内存模型对一个线程所作的变更能被其它线程可见提供了保证,它们之间是先行发生关系。

  • 线程内的代码可以按前后顺序执行,这被称为程序次序规则
  • 对于同一个锁,一个解锁操做必定要发生在时间上后发生的另外一个锁定操做以前,也叫作管程锁定规则
  • 前一个对 volatile 的写操做在后一个 volatile 的读操做以前,也叫 volatile 变量规则
  • 一个线程内的任何操做必需在这个线程的 start()调用以后,也叫做线程启动规则
  • 一个线程的全部操做都会在线程终止以前,线程终止规则
  • 一个对象的终结操做必需在这个对象构造完成以后,也叫对象终结规则

对于程序次序规则来讲,就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操做先行发生于书写在后面的操做”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,由于虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,可是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。所以,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但没法保证程序在多线程中执行的正确性。


进程,线程与协程

在未配置 OS 的系统中,程序的执行方式是顺序执行,即必须在一个程序执行完后,才容许另外一个程序执行;在多道程序环境下,则容许多个程序并发执行。程序的这两种执行方式间有着显著的不一样。也正是程序并发执行时的这种特征,才致使了在操做系统中引入进程的概念。进程是资源分配的基本单位,线程是资源调度的基本单位

早期的操做系统基于进程来调度 CPU,不一样进程间是不共享内存空间的,因此进程要作任务切换就要切换内存映射地址,而一个进程建立的全部线程,都是共享一个内存空间的,因此线程作任务切换成本就很低了。现代的操做系统都基于更轻量的线程来调度,如今咱们提到的“任务切换”都是指“线程切换”。

Process | 进程

进程是操做系统对一个正在运行的程序的一种抽象,在一个系统上能够同时运行多个进程,而每一个进程都好像在独占地使用硬件。所谓的并发运行,则是说一个进程的指令和另外一个进程的指令是交错执行的。不管是在单核仍是多核系统中,能够经过处理器在进程间切换,来实现单个 CPU 看上去像是在并发地执行多个进程。操做系统实现这种交错执行的机制称为上下文切换。

操做系统保持跟踪进程运行所需的全部状态信息。这种状态,也就是上下文,它包括许多信息,例如 PC 和寄存器文件的当前值,以及主存的内容。在任何一个时刻,单处理器系统都只能执行一个进程的代码。当操做系统决定要把控制权从当前进程转移到某个新进程时,就会进行上下文切换,即保存当前进程的上下文、恢复新进程的上下文,而后将控制权传递到新进程。新进程就会从上次中止的地方开始。

image

虚拟存储管理 https://url.wx-coder.cn/PeNqS 一节中,咱们介绍过它为每一个进程提供了一个假象,即每一个进程都在独占地使用主存。每一个进程看到的是一致的存储器,称为虚拟地址空间。其虚拟地址空间最上面的区域是为操做系统中的代码和数据保留的,这对全部进程来讲都是同样的;地址空间的底部区域存放用户进程定义的代码和数据。

image

  • 程序代码和数据,对于全部的进程来讲,代码是从同一固定地址开始,直接按照可执行目标文件的内容初始化。
  • 堆,代码和数据区后紧随着的是运行时堆。代码和数据区是在进程一开始运行时就被规定了大小,与此不一样,当调用如 malloc 和 free 这样的 C 标准库函数时,堆能够在运行时动态地扩展和收缩。
  • 共享库:大约在地址空间的中间部分是一块用来存放像 C 标准库和数学库这样共享库的代码和数据的区域。
  • 栈,位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数调用。和堆同样,用户栈在程序执行期间能够动态地扩展和收缩。
  • 内核虚拟存储器:内核老是驻留在内存中,是操做系统的一部分。地址空间顶部的区域是为内核保留的,不容许应用程序读写这个区域的内容或者直接调用内核代码定义的函数。

Thread | 线程

在现代系统中,一个进程实际上能够由多个称为线程的执行单元组成,每一个线程都运行在进程的上下文中,并共享一样的代码和全局数据。进程的个体间是彻底独立的,而线程间是彼此依存的。多进程环境中,任何一个进程的终止,不会影响到其余进程。而多线程环境中,父线程终止,所有子线程被迫终止(没有了资源)。而任何一个子线程终止通常不会影响其余线程,除非子线程执行了 exit() 系统调用。任何一个子线程执行 exit(),所有线程同时灭亡。多线程程序中至少有一个主线程,而这个主线程其实就是有 main 函数的进程。它是整个程序的进程,全部线程都是它的子线程。咱们一般把具备多线程的主进程称之为主线程。

线程共享的环境包括:进程代码段、进程的公有数据、进程打开的文件描述符、信号的处理器、进程的当前目录、进程用户 ID 与进程组 ID 等,利用这些共享的数据,线程很容易的实现相互之间的通信。进程拥有这许多共性的同时,还拥有本身的个性,并以此实现并发性:

  • 线程 ID:每一个线程都有本身的线程 ID,这个 ID 在本进程中是惟一的。进程用此来标识线程。
  • 寄存器组的值:因为线程间是并发运行的,每一个线程有本身不一样的运行线索,当从一个线程切换到另外一个线程上时,必须将原有的线程的寄存器集合的状态保存,以便 未来该线程在被从新切换到时能得以恢复。
  • 线程的堆栈:堆栈是保证线程独立运行所必须的。线程函数能够调用函数,而被调用函数中又是能够层层嵌套的,因此线程必须拥有本身的函数堆栈, 使得函数调用能够正常执行,不受其余线程的影响。
  • 错误返回码:因为同一个进程中有不少个线程在同时运行,可能某个线程进行系统调用后设置了 errno 值,而在该 线程尚未处理这个错误,另一个线程就在此时 被调度器投入运行,这样错误值就有可能被修改。 因此,不一样的线程应该拥有本身的错误返回码变量。
  • 线程的信号屏蔽码:因为每一个线程所感兴趣的信号不一样,因此线程的信号屏蔽码应该由线程本身管理。但全部的线程都共享一样的信号处理器。
  • 线程的优先级:因为线程须要像进程那样可以被调度,那么就必需要有可供调度使用的参数,这个参数就是线程的优先级。

image.png

Linux 中的线程

在 Linux 2.4 版之前,线程的实现和管理方式就是彻底按照进程方式实现的;在 Linux 2.6 以前,内核并不支持线程的概念,仅经过轻量级进程(lightweight process)模拟线程,一个用户线程对应一个内核线程(内核轻量级进程),这种模型最大的特色是线程调度由内核完成了,而其余线程操做(同步、取消)等都是核外的线程库(LinuxThread)函数完成的。为了彻底兼容 Posix 标准,Linux 2.6 首先对内核进行了改进,引入了线程组的概念(仍然用轻量级进程表示线程),有了这个概念就能够将一组线程组织称为一个进程,不过内核并无准备特别的调度算法或是定义特别的数据结构来表征线程;相反,线程仅仅被视为一个与其余进程(概念上应该是线程)共享某些资源的进程(概念上应该是线程)。在实现上主要的改变就是在 task_struct 中加入 tgid 字段,这个字段就是用于表示线程组 id 的字段。在用户线程库方面,也使用 NPTL 代替 LinuxThread。不一样调度模型上仍然采用 1 对 1 模型。

进程的实现是调用 fork 系统调用:pid_t fork(void);,线程的实现是调用 clone 系统调用:int clone(int (*fn)(void *), void *child_stack, int flags, void *arg, ...)。与标准 fork() 相比,线程带来的开销很是小,内核无需单独复制进程的内存空间或文件描写叙述符等等。这就节省了大量的 CPU 时间,使得线程建立比新进程建立快上十到一百倍,可以大量使用线程而无需太过于操心带来的 CPU 或内存不足。不管是 fork、vfork、kthread_create 最后都是要调用 do_fork,而 do_fork 就是根据不一样的函数参数,对一个进程所需的资源进行分配。

线程池

线程池的大小依赖于所执行任务的特性以及程序运行的环境,线程池的大小应该应采起可配置的方式(写入配置文件)或者根据可用的 CPU 数量 Runtime.availableProcessors() 来进行设置,其中 Ncpu 表示可用 CPU 数量,Nthreads 表示线程池工做线程数量,Ucpu 表示 CPU 的利用率 0≤ Ucpu ≤1;W 表示资源等待时间,C 表示任务计算时间;Rtotal 表示有限资源的总量,Rper 表示每一个任务须要的资源数量。

  • 对于对于纯 CPU 计算的任务-即不依赖阻塞资源(外部接口调用)以及有限资源(线程池)的 CPU 密集型(compute-intensive)任务线程池的大小能够设置为:Nthreads = Ncpu+1

  • 若是执行的任务除了 cpu 计算还包括一些外部接口调用或其余会阻塞的计算,那么线程池的大小能够设置为 Nthreads = Ncpu - Ucpu -(1 + W / C)。能够看出对于 IO 等待时间长于任务计算时间的状况,W/C 大于 1,假设 cpu 利用率是 100%,那么 W/C 结果越大,须要的工做线程也越多,由于若是没有足够的线程则会形成任务队列迅速膨胀。

  • 若是任务依赖于一些有限的资源好比内存,文件句柄,数据库链接等等,那么线程池最大能够设置为 Nthreads ≤ Rtotal/Rper

Coroutine | 协程

协程是用户模式下的轻量级线程,最准确的名字应该叫用户空间线程(User Space Thread),在不一样的领域中也有不一样的叫法,譬如纤程(Fiber)、绿色线程(Green Thread)等等。操做系统内核对协程一无所知,协程的调度彻底有应用程序来控制,操做系统无论这部分的调度;一个线程能够包含一个或多个协程,协程拥有本身的寄存器上下文和栈,协程调度切换时,将寄存器上细纹和栈保存起来,在切换回来时恢复先前保运的寄存上下文和栈。

好比 Golang 里的 go 关键字其实就是负责开启一个 Fiber,让 func 逻辑跑在上面。而这一切都是发生的用户态上,没有发生在内核态上,也就是说没有 ContextSwitch 上的开销。协程的实现库中笔者较为经常使用的譬如 Go Routine、node-fibersJava-Quasar 等。

Go 的栈是动态分配大小的,随着存储数据的数量而增加和收缩。每一个新建的 Goroutine 只有大约 4KB 的栈。每一个栈只有 4KB,那么在一个 1GB 的 RAM 上,咱们就能够有 256 万个 Goroutine 了,相对于 Java 中每一个线程的 1MB,这是巨大的提高。Golang 实现了本身的调度器,容许众多的 Goroutines 运行在相同的 OS 线程上。就算 Go 会运行与内核相同的上下文切换,可是它可以避免切换至 ring-0 以运行内核,而后再切换回来,这样就会节省大量的时间。可是,这只是纸面上的分析。为了支持上百万的 Goroutines,Go 须要完成更复杂的事情。

要支持真正的大并发须要另一项优化:当你知道线程可以作有用的工做时,才去调度它。若是你运行大量线程的话,其实只有少许的线程会执行有用的工做。Go 经过集成通道(channel)和调度器(scheduler)来实现这一点。若是某个 Goroutine 在一个空的通道上等待,那么调度器会看到这一点而且不会运行该 Goroutine。Go 更近一步,将大多数空闲的线程都放到它的操做系统线程上。经过这种方式,活跃的 Goroutine(预期数量会少得多)会在同一个线程上调度执行,而数以百万计的大多数休眠的 Goroutine 会单独处理。这样有助于下降延迟。

除非 Java 增长语言特性,容许调度器进行观察,不然的话,是不可能支持智能调度的。可是,你能够在“用户空间”中构建运行时调度器,它可以感知线程什么时候可以执行工做。这构成了像 Akka 这种类型的框架的基础,它可以支持上百万的 Actor。


并发控制

涉及多线程程序涉及的时候常常会出现一些使人难以思议的事情,用堆和栈分配一个变量可能在之后的执行中产生意想不到的结果,而这个结果的表现就是内存的非法被访问,致使内存的内容被更改。在一个进程的线程共享堆区,而进程中的线程各自维持本身堆栈。 在 Windows 等平台上,不一样线程缺省使用同一个堆,因此用 C 的 malloc (或者 windows 的 GlobalAlloc)分配内存的时候是使用了同步保护的。若是没有同步保护,在两个线程同时执行内存操做的时候会产生竞争条件,可能致使堆内内存管理混乱。好比两个线程分配了统一块内存地址,空闲链表指针错误等。

最多见的进程/线程的同步方法有互斥锁(或称互斥量 Mutex),读写锁(rdlock),条件变量(cond),信号量(Semophore)等;在 Windows 系统中,临界区(Critical Section)和事件对象(Event)也是经常使用的同步方法。总结而言,同步问题基本的就是解决原子性与可见性/一致性这两个问题,其基本手段就是基于锁,所以又能够分为三个方面:指令串行化/临界资源管理/锁、数据一致性/数据可见性、事务/原子操做。在并发控制中咱们会考虑线程协做、互斥与锁、并发容器等方面。

线程通讯

并发控制中主要考虑线程之间的通讯(线程之间以何种机制来交换信息)与同步(读写等待,竞态条件等)模型,在命令式编程中,线程之间的通讯机制有两种:共享内存和消息传递。Java 就是典型的共享内存模式的通讯机制;而 Go 则是提倡以消息传递方式实现内存共享,而非经过共享来实现通讯。

在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间经过写-读内存中的公共状态来隐式进行通讯。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须经过明确的发送消息来显式进行通讯。同步是指程序用于控制不一样线程之间操做发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码须要在线程之间互斥执行。在消息传递的并发模型里,因为消息的发送必须在消息的接收以前,所以同步是隐式进行的。

常见的线程通讯方式有如下几种:

  • 管道(Pipe):管道是一种半双工的通讯方式,数据只能单向流动,并且只能在具备亲缘关系的进程间使用,其中进程的亲缘关系一般是指父子进程关系。

  • 消息队列(Message Queue):消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。

  • 信号量(Semophore):信号量是一个计数器,能够用来控制多个进程对共享资源的访问。它常做为一种锁机制,防止某进程正在访问共享资源时,其余进程也访问该资源。所以,主要做为进程间以及同一进程内不一样线程之间的同步手段。

  • 共享内存(Shared Memory):共享内存就是映射一段能被其余进程所访问的内存,这段共享内存由一个进程建立,但多个进程均可以访问。共享内存是最快的 IPC 方式,它是针对其余进程间通讯方式运行效率低而专门设计的。它每每与其余通讯机制,如信号量配合使用,来实现进程间的同步和通讯。

  • 套接字(Socket):套接字也是一种进程间通讯机制,与其余通讯机制不一样的是,它可用于不一样主机间的进程通讯。

锁与互斥

互斥是指某一资源同时只容许一个访问者对其进行访问,具备惟一性和排它性;但互斥没法限制访问者对资源的访问顺序,即访问是无序的。同步:是指在互斥的基础上(大多数状况),经过其它机制实现访问者对资源的有序访问。在大多数状况下,同步已经实现了互斥,特别是全部写入资源的状况一定是互斥的;少数状况是指能够容许多个访问者同时访问资源。

临界资源

所谓的临界资源,即一次只容许一个进程访问的资源,多个进程只能互斥访问的资源。临界资源的访问须要同步操做,好比信号量就是一种方便有效的进程同步机制。但信号量的方式要求每一个访问临界资源的进程都具备 wait 和 signal 操做。这样使大量的同步操做分散在各个进程中,不只给系统管理带来了麻烦,并且会因同步操做的使用不当致使死锁。管程就是为了解决这样的问题而产生的。

操做系统中管理的各类软件和硬件资源,都可用数据结构抽象地描述其资源特性,即用少许信息和对该资源所执行的操做来表征该资源,而忽略它们的内部结构和实现细节。利用共享数据结构抽象地表示系统中的共享资源。而把对该共享数据结构实施的操做定义为一组过程,如资源的请求和释放过程 request 和 release。进程对共享资源的申请、释放和其余操做,都是经过这组过程对共享数据结构的操做来实现的,这组过程还能够根据资源的状况接受或阻塞进程的访问,确保每次仅有一个进程使用该共享资源,这样就能够统一管理对共享资源的全部访问,实现临界资源互斥访问。

管程就是表明共享资源的数据结构以及由对该共享数据结构实施操做的一组过程所组成的资源管理程序共同构成的一个操做系统的资源管理模块。管程被请求和释放临界资源的进程所调用。管程定义了一个数据结构和能为并发进程所执行(在该数据结构上)的一组操做,这组操做能同步进程和改变管程中的数据。

悲观锁(Pessimistic Locking)

悲观并发控制,又名悲观锁(Pessimistic Concurrency Control,PCC)是一种并发控制的方法。它能够阻止一个事务以影响其余用户的方式来修改数据。若是一个事务执行的操做都某行数据应用了锁,那只有当这个事务把锁释放,其余事务才可以执行与该锁冲突的操做。悲观并发控制主要用于数据争用激烈的环境,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中。

在编程语言中,悲观锁可能存在如下缺陷:

  • 在多线程竞争下,加锁、释放锁会致使比较多的上下文切换和调度延时,引发性能问题。
  • 一个线程持有锁会致使其它全部须要此锁的线程挂起。
  • 若是一个优先级高的线程等待一个优先级低的线程释放锁会致使优先级倒置,引发性能风险。

数据库中悲观锁主要由如下问题:悲观锁大多数状况下依靠数据库的锁机制实现,以保证操做最大程度的独占性。若是加锁的时间过长,其余用户长时间没法访问,影响了程序的并发访问性,同时这样对数据库性能开销影响也很大,特别是对长事务而言,这样的开销每每没法承受,特别是对长事务而言。如一个金融系统,当某个操做员读取用户的数据,并在读出的用户数据的基础上进行修改时(如更改用户账户余额),若是采用悲观锁机制,也就意味着整个操做过程当中(从操做员读出数据、开始修改直至提交修改结果的全过程,甚至还包括操做员中途去煮咖啡的时间),数据库记录始终处于加锁状态,能够想见,若是面对几百上千个并发,这样的状况将致使怎样的后果。

互斥锁/排他锁

互斥锁即对互斥量进行分加锁,和自旋锁相似,惟一不一样的是竞争不到锁的线程会回去睡会觉,等到锁可用再来竞争,第一个切入的线程加锁后,其余竞争失败者继续回去睡觉直到再次接到通知、竞争。

互斥锁算是目前并发系统中最经常使用的一种锁,POSIX、C++十一、Java 等均支持。处理 POSIX 的加锁比较普通外,C++ 和 Java 的加锁方式颇有意思。C++ 中能够使用一种 AutoLock(常见于 chromium 等开源项目中)工做方式相似 auto_ptr 智 能指针,在 C++11 中官方将其标准化为 std::lock_guard 和 std::unique_lock。Java 中使用 synchronized 紧跟同步代码块(也可修饰方法)的方式同步代码,很是灵活。这两种实现都巧妙的利用各自语言特性实现了很是优雅的加锁方式。固然除此以外他们也支持传统的类 似于 POSIX 的加锁模式。

可重入锁

也叫作锁递归,就是获取一个已经获取的锁。不支持线程获取它已经获取且还没有解锁的方式叫作不可递归或不支持重入。带重入特性的锁在重入时会判断是否同一个线程,若是是,则使持锁计数器+1(0 表明没有被线程获取,又或者是锁被释放)。C++11 中同时支持两种锁,递归锁 std::recursive_mutex 和非递归 std::mutex。Java 的两种互斥锁实现以及读写锁实现均支持重入。POSIX 使用一种叫作重入函数的方法保证函数的线程安全,锁粒度是调用而非线程。

读写锁

支持两种模式的锁,当采用写模式上锁时与互斥锁相同,是独占模式。但读模式上锁能够被多个读线程读取。即写时使用互斥锁,读时采用共享锁,故又叫共享-独 占锁。一种常见的错误认为数据只有在写入时才须要锁,事实是即便是读操做也须要锁保护,若是不这么作的话,读写锁的读模式便毫无心义。

乐观锁(Optimistic Locking)

相对悲观锁而言,乐观锁(Optimistic Locking)机制采起了更加宽松的加锁机制。相对悲观锁而言,乐观锁假设认为数据通常状况下不会形成冲突,因此在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,若是发现冲突了,则让返回用户错误的信息,让用户决定如何去作。上面提到的乐观锁的概念中其实已经阐述了他的具体实现细节:主要就是两个步骤:冲突检测和数据更新。其实现方式有一种比较典型的就是 Compare and Swap。

CAS 与 ABA

CAS 是项乐观锁技术,当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知此次竞争中失败,并能够再次尝试。CAS 操做包含三个操做数 —— 内存位置(V)、预期原值(A)和新值(B)。若是内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。不然,处理器不作任何操做。不管哪一种状况,它都会在 CAS 指令以前返回该位置的值。CAS 有效地说明了我认为位置 V 应该包含值 A;若是包含该值,则将 B 放到这个位置;不然,不要更改该位置,只告诉我这个位置如今的值便可。这其实和乐观锁的冲突检查+数据更新的原理是同样的。

乐观锁也不是万能的,乐观并发控制相信事务之间的数据竞争(Data Race)的几率是比较小的,所以尽量直接作下去,直到提交的时候才去锁定,因此不会产生任何锁和死锁。但若是直接简单这么作,仍是有可能会遇到不可预期的结果,例如两个事务都读取了数据库的某一行,通过修改之后写回数据库,这时就遇到了问题。

  • 乐观锁只能保证一个共享变量的原子操做。如上例子,自旋过程当中只能保证 value 变量的原子性,这时若是多一个或几个变量,乐观锁将变得力不从心,但互斥锁能轻易解决,无论对象数量多少及对象颗粒度大小。
  • 长时间自旋可能致使开销大。假如 CAS 长时间不成功而一直自旋,会给 CPU 带来很大的开销。
  • ABA 问题。

CAS 的核心思想是经过比对内存值与预期值是否同样而判断内存值是否被改过,但这个判断逻辑不严谨,假如内存值原来是 A,后来被 一条线程改成 B,最后又被改为了 A,则 CAS 认为此内存值并无发生改变,但其实是有被其余线程改过的,这种状况对依赖过程值的情景的运算结果影响很大。解决的思路是引入版本号,每次变量更新都把版本号加一。部分乐观锁的实现是经过版本号(version)的方式来解决 ABA 问题,乐观锁每次在执行数据的修改操做时,都会带上一个版本号,一旦版本号和数据的版本号一致就能够执行修改操做并对版本号执行 +1 操做,不然就执行失败。由于每次操做的版本号都会随之增长,因此不会出现 ABA 问题,由于版本号只会增长不会减小。

自旋锁

Linux 内核中最多见的锁,做用是在多核处理器间同步数据。这里的自旋是忙等待的意思。若是一个线程(这里指的是内核线程)已经持有了一个自旋锁,而另外一条线程也想要获取该锁,它就不停地循环等待,或者叫作自旋等待直到锁可用。能够想象这种锁不能被某个线程长时间持有,这会致使其余线程一直自旋,消耗处理器。因此,自旋锁使用范围很窄,只容许短时间内加锁。

其实还有一种方式就是让等待线程睡眠直到锁可用,这样就能够消除忙等待。很明显后者优于前者的实现,可是却不适用于此,若是咱们使用第二种方式,咱们要作几步操做:把该等待线程换出、等到锁可用在换入,有两次上下文切换的代价。这个代价和短期内自旋(实现起来也简单)相比,后者更能适应实际状况的须要。还有一点须要注意,试图获取一个已经持有自旋锁的线程再去获取这个自旋锁或致使死锁,但其余操做系统并不是如此。

自旋锁与互斥锁有点相似,只是自旋锁不会引发调用者睡眠,若是自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是 否该自旋锁的保持者已经释放了锁,"自旋"一词就是所以而得名。其做用是为了解决某项资源的互斥使用。由于自旋锁不会引发调用者睡眠,因此自旋锁的效率远 高于互斥锁。虽然它的效率比互斥锁高,可是它也有些不足之处:

  • 自旋锁一直占用 CPU,他在未得到锁的状况下,一直运行--自旋,因此占用着 CPU,若是不能在很短的时 间内得到锁,这无疑会使 CPU 效率下降。
  • 在用自旋锁时有可能形成死锁,当递归调用时有可能形成死锁,调用有些其余函数也可能形成死锁,如 copy_to_user()、copy_from_user()、kmalloc()等。

自旋锁比较适用于锁使用者保持锁时间比较短的状况。正是因为自旋锁使用者通常保持锁时间很是短,所以选择自旋而不是睡眠是很是必要的,自旋锁的效率远高于互斥锁。信号量和读写信号量适合于保持时间较长的状况,它们会致使调用者睡眠,所以只能在进程上下文使用,而自旋锁适合于保持时间很是短的状况,它能够在任何上下文使用。若是被保护的共享资源只在进程上下文访问,使用信号量保护该共享资源很是合适,若是对共享资源的访问时间很是短,自旋锁也能够。可是若是被保护的共享资源须要在中断上下文访问(包括底半部即中断处理句柄和顶半部即软中断),就必须使用自旋锁。自旋锁保持期间是抢占失效的,而信号量和读写信号量保持期间是能够被抢占的。自旋锁只有在内核可抢占或 SMP(多处理器)的状况下才真正须要,在单 CPU 且不可抢占的内核下,自旋锁的全部操做都是空操做。另外格外注意一点:自旋锁不能递归使用。

MVCC

为了实现可串行化,同时避免锁机制存在的各类问题,咱们能够采用基于多版本并发控制(Multiversion concurrency control,MVCC)思想的无锁事务机制。人们通常把基于锁的并发控制机制称成为悲观机制,而把 MVCC 机制称为乐观机制。这是由于锁机制是一种预防性的,读会阻塞写,写也会阻塞读,当锁定粒度较大,时间较长时并发性能就不会太好;而 MVCC 是一种后验性的,读不阻塞写,写也不阻塞读,等到提交的时候才检验是否有冲突,因为没有锁,因此读写不会相互阻塞,从而大大提高了并发性能。咱们能够借用源代码版本控制来理解 MVCC,每一个人均可以自由地阅读和修改本地的代码,相互之间不会阻塞,只在提交的时候版本控制器会检查冲突,并提示 merge。目前,Oracle、PostgreSQL 和 MySQL 都已支持基于 MVCC 的并发机制,但具体实现各有不一样。

MVCC 的一种简单实现是基于 CAS(Compare-and-swap)思想的有条件更新(Conditional Update)。普通的 update 参数只包含了一个 keyValueSet’,Conditional Update 在此基础上加上了一组更新条件 conditionSet { … data[keyx]=valuex, … },即只有在 D 知足更新条件的状况下才将数据更新为 keyValueSet’;不然,返回错误信息。这样,L 就造成了以下图所示的 Try/Conditional Update/(Try again) 的处理模式:

对于常见的修改用户账户信息的例子而言,假设数据库中账户信息表中有一个 version 字段,当前值为 1 ;而当前账户余额字段(balance)为 100。

  • 操做员 A 此时将其读出(version=1),并从其账户余额中扣除 50 (100-50)。

  • 在操做员 A 操做的过程当中,操做员 B 也读入此用户信息(version=1),并从其账户余额中扣除 20 (100-20)。

  • 操做员 A 完成了修改工做,将数据版本号加一(version=2),连同账户扣除后余额(balance=50),提交至数据库更新,此时因为提交数据版本大于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。

  • 操做员 B 完成了操做,也将版本号加一(version=2)试图向数据库提交数据(balance=80),但此时比对数据库记录版本时发现,操做员 B 提交的数据版本号为 2 ,数据库记录当前版本也为 2 ,不知足提交版本必须大于记录当前版本才能执行更新的乐观锁策略,所以,操做员 B 的提交被驳回。这样,就避免了操做员 B 用基于 version=1 的旧数据修改的结果覆盖操做员 A 的操做结果的可能。

从上面的例子能够看出,乐观锁机制避免了长事务中的数据库加锁开销(操做员 A 和操做员 B 操做过程当中,都没有对数据库数据加锁),大大提高了大并发量下的系统总体性能表现。须要注意的是,乐观锁机制每每基于系统中的数据存储逻辑,所以也具有必定的局限性,如在上例中,因为乐观锁机制是在咱们的系统中实现,来自外部系统的用户余额更新操做不受咱们系统的控制,所以可能会形成脏数据被更新到数据库中。


并发 IO

IO 的概念,从字义来理解就是输入输出。操做系统从上层到底层,各个层次之间均存在 IO。好比,CPU 有 IO,内存有 IO, VMM 有 IO, 底层磁盘上也有 IO,这是广义上的 IO。一般来说,一个上层的 IO 可能会产生针对磁盘的多个 IO,也就是说,上层的 IO 是稀疏的,下层的 IO 是密集的。磁盘的 IO,顾名思义就是磁盘的输入输出。输入指的是对磁盘写入数据,输出指的是从磁盘读出数据。

所谓的并发 IO,即在一个时间片内,若是一个进程进行一个 IO 操做,例如读个文件,这个时候该进程能够把本身标记为“休眠状态”并出让 CPU 的使用权,待文件读进内存,操做系统会把这个休眠的进程唤醒,唤醒后的进程就有机会从新得到 CPU 的使用权了。这里的进程在等待 IO 时之因此会释放 CPU 使用权,是为了让 CPU 在这段等待时间里能够作别的事情,这样一来 CPU 的使用率就上来了;此外,若是这时有另一个进程也读文件,读文件的操做就会排队,磁盘驱动在完成一个进程的读操做后,发现有排队的任务,就会当即启动下一个读操做,这样 IO 的使用率也上来了。

IO 类型

Unix 中内置了 5 种 IO 模型,阻塞式 IO, 非阻塞式 IO,IO 复用模型,信号驱动式 IO 和异步 IO。而从应用的角度来看,IO 的类型能够分为:

  • 大/小块 IO:这个数值指的是控制器指令中给出的连续读出扇区数目的多少。若是数目较多,如 64,128 等,咱们能够认为是大块 IO;反之,若是很小,好比 4,8,咱们就会认为是小块 IO,实际上,在大块和小块 IO 之间,没有明确的界限。

  • 连续/随机 IO:连续 IO 指的是本次 IO 给出的初始扇区地址和上一次 IO 的结束扇区地址是彻底连续或者相隔很少的。反之,若是相差很大,则算做一次随机 IO。连续 IO 比随机 IO 效率高的缘由是:在作连续 IO 的时候,磁头几乎不用换道,或者换道的时间很短;而对于随机 IO,若是这个 IO 不少的话,会致使磁头不停地换道,形成效率的极大下降。

  • 顺序/并发 IO:从概念上讲,并发 IO 就是指向一块磁盘发出一条 IO 指令后,没必要等待它回应,接着向另一块磁盘发 IO 指令。对于具备条带性的 RAID(LUN),对其进行的 IO 操做是并发的,例如:raid 0+1(1+0),raid5 等。反之则为顺序 IO。

在传统的网络服务器的构建中,IO 模式会按照 Blocking/Non-Blocking、Synchronous/Asynchronous 这两个标准进行分类,其中 Blocking 与 Synchronous 大同小异,而 NIO 与 Async 的区别在于 NIO 强调的是 轮询(Polling),而 Async 强调的是通知(Notification)。譬如在一个典型的单进程单线程 Socket 接口中,阻塞型的接口必须在上一个 Socket 链接关闭以后才能接入下一个 Socket 链接。而对于 NIO 的 Socket 而言,服务端应用会从内核获取到一个特殊的 "Would Block" 错误信息,可是并不会阻塞到等待发起请求的 Socket 客户端中止。

通常来讲,在 Linux 系统中能够经过调用独立的 select 或者 epoll 方法来遍历全部读取好的数据,而且进行写操做。而对于异步 Socket 而言(譬如 Windows 中的 Sockets 或者 .Net 中实现的 Sockets 模型),服务端应用会告诉 IO Framework 去读取某个 Socket 数据,在数据读取完毕以后 IO Framework 会自动地调用你的回调(也就是通知应用程序自己数据已经准备好了)。以 IO 多路复用中的 Reactor 与 Proactor 模型为例,非阻塞的模型是须要应用程序自己处理 IO 的,而异步模型则是由 Kernel 或者 Framework 将数据准备好读入缓冲区中,应用程序直接从缓冲区读取数据。

  • 同步阻塞:在此种方式下,用户进程在发起一个 IO 操做之后,必须等待 IO 操做的完成,只有当真正完成了 IO 操做之后,用户进程才能运行。

  • 同步非阻塞:在此种方式下,用户进程发起一个 IO 操做之后边可返回作其它事情,可是用户进程须要时不时的询问 IO 操做是否就绪,这就要求用户进程不停的去询问,从而引入没必要要的 CPU 资源浪费。

  • 异步非阻塞:在此种模式下,用户进程只须要发起一个 IO 操做而后当即返回,等 IO 操做真正的完成之后,应用程序会获得 IO 操做完成的通知,此时用户进程只须要对数据进行处理就行了,不须要进行实际的 IO 读写操做,由于真正的 IO 读取或者写入操做已经由内核完成了。

而在并发 IO 的问题中,较常见的就是所谓的 C10K 问题,即有 10000 个客户端须要连上一个服务器并保持 TCP 链接,客户端会不定时的发送请求给服务器,服务器收到请求后需及时处理并返回结果。

IO 多路复用

IO 多路复用就经过一种机制,能够监视多个描述符,一旦某个描述符就绪(通常是读就绪或者写就绪),可以通知程序进行相应的读写操做。select,poll,epoll 都是 IO 多路复用的机制。值得一提的是,epoll 仅对于 Pipe 或者 Socket 这样的读写阻塞型 IO 起做用,正常的文件描述符则是会马上返回文件的内容,所以 epoll 等函数对普通的文件读写并没有做用。

首先来看下可读事件与可写事件:当以下任一状况发生时,会产生套接字的可读事件:

  • 该套接字的接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记的大小;
  • 该套接字的读半部关闭(也就是收到了 FIN),对这样的套接字的读操做将返回 0(也就是返回 EOF);
  • 该套接字是一个监听套接字且已完成的链接数不为 0;
  • 该套接字有错误待处理,对这样的套接字的读操做将返回-1。

当以下任一状况发生时,会产生套接字的可写事件:

  • 该套接字的发送缓冲区中的可用空间字节数大于等于套接字发送缓冲区低水位标记的大小;
  • 该套接字的写半部关闭,继续写会产生 SIGPIPE 信号;
  • 非阻塞模式下,connect 返回以后,该套接字链接成功或失败;
  • 该套接字有错误待处理,对这样的套接字的写操做将返回-1。

select,poll,epoll 本质上都是同步 IO,由于他们都须要在读写事件就绪后本身负责进行读写,也就是说这个读写过程是阻塞的,而异步 IO 则无需本身负责进行读写,异步 IO 的实现会负责把数据从内核拷贝到用户空间。select 自己是轮询式、无状态的,每次调用都须要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 不少时会很大。epoll 则是触发式处理链接,维护的描述符数目不受到限制,并且性能不会随着描述符数目的增长而降低。

方法 数量限制 链接处理 内存操做
select 描述符个数由内核中的 FD_SETSIZE 限制,仅为 1024;从新编译内核改变 FD_SETSIZE 的值,可是没法优化性能 每次调用 select 都会线性扫描全部描述符的状态,在 select 结束后,用户也要线性扫描 fd_set 数组才知道哪些描述符准备就绪(O(n)) 每次调用 select 都要在用户空间和内核空间里进行内存复制 fd 描述符等信息
poll 使用 pollfd 结构来存储 fd,突破了 select 中描述符数目的限制 相似于 select 扫描方式 须要将 pollfd 数组拷贝到内核空间,以后依次扫描 fd 的状态,总体复杂度依然是 O(n)的,在并发量大的状况下服务器性能会快速降低
epoll 该模式下的 Socket 对应的 fd 列表由一个数组来保存,大小不限制(默认 4k) 基于内核提供的反射模式,有活跃 Socket 时,内核访问该 Socket 的 callback,不须要遍历轮询 epoll 在传递内核与用户空间的消息时使用了内存共享,而不是内存拷贝,这也使得 epoll 的效率比 poll 和 select 更高
相关文章
相关标签/搜索