进程与线程

  • 进程:【资源分配单位】描述的就是程序的执行过程,是运行着的程序的代表,一个进程其实就是某个程序运行的一个产物。(静静躺在那里的代码是程序、奔跑着、正在发挥功能的代码就是进程)。

  • 线程:【CPU调度单位】总是在进程之内,被视为进程中运行着的控制流(代码执行流程)。

每个进程中的内容

每个线程中的内容

地址空间

程序计数器

全局变量

寄存器

文件句柄

子进程

状态

即将发送的定时器

信号与信号处理程序

账户信息

  • 一个进程至少包含一个线程,如果只包含一个线程,那么所有代码会被串行执行。每个进程的第一个线程都会随着该进程的启动而被创建,称它为所属进程的主线程

  • 如果一个进程包含多个线程,那么代码可以被并发(单个CPU)地执行,除了主线程,其他线程都是进程中已存在的线程创建出来的主线程之外的其他线程,只能由代码显式地创建和销毁,各个线程之间可以共享地址空间和文件等资源。但是,当进程中的一个线程奔溃时,会导致其所属进程的所有线程奔溃。

进程

进程的状态

  • 运行状态(Runing):该时刻进程占用 CPU

  • 就绪状态(Ready):可运行,但因为其他进程正在运行而暂停停止

  • 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行

  • 创建状态(new):进程正在被创建时的状态

  • 结束状态(Exit):进程正在从系统中消失时的状态

  • NULL -> 创建状态:一个新进程被创建时的第一个状态

  • 创建状态 -> 就绪状态:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的

  • 就绪态 -> 运行状态:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运行该进程

  • 运行状态 -> 结束状态:当进程已经运行完成或出错时,会被操作系统作结束状态处理

  • 运行状态 -> 就绪状态:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统会把该进程变为就绪态,接着从就绪态选中另外一个进程运行

  • 运行状态 -> 阻塞状态:当进程请求某个事件且必须等待时,例如请求 I/O 事件

  • 阻塞状态 -> 就绪状态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态

特殊状态:挂起,表示进程没有占有物理内存空间。

由于虚拟内存管理原因,进程所使用的空间可能并没有映射到物理内存,而是在硬盘上,这时进程就会出现挂起状态,另外调用 sleep 也会被挂起。

挂起状态可以分为两种:

  • 阻塞挂起状态:进程在外存(硬盘),等待某个事件的出现

  • 就绪挂起状态:进程在外存(硬盘),只要进入内存,立刻运行

进程的控制结构

在操作系统中,使用进程控制块(process control block,PCB)数据结构来描述进程。

PCB 是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。

PCB包含的内容:

  • 进程描述信息:

    • 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符

    • 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务

  • 进程控制和管理信息:

    • 进程当前状态,如 new、ready、running、waiting 或 blocked 等

    • 进程优先级:进程抢占 CPU 时的优先级

  • 资源分配清单:有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息

  • CPU 相关信息:CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行

PCB通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。比如:

  • 就绪队列:将所有处于就绪状态的进程链在一起

  • 各种阻塞队列:把所有因等待某事件而处于等待状态的进程链在一起

  • 运行队列:在单核 CPU 系统中只有一个运行指针(因为单核 CPU 在某个时间,只能运行一个程序)

进程的上下文切换

各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执行,这样从一个进程切换到另一个进程运行,称为进程的上下文切换。

CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下文。

CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

系统内核会存储保持下来的上下文信息,当此任务再次被分配给 CPU 运行时,CPU 会重新加载这些上下文,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。

所谓的「任务」,主要包含进程、线程和中断。根据任务的不同,把 CPU 上下文切换分成:进程上下文切换、线程上下文切换和中断上下文切换。

进程是由内核管理和调度的,所以进程的切换只能发生在内核态。进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。

进程上下文切换的场景

  • 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,就会被系统挂起,切换到其它正在等待 CPU 的进程运行;

  • 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行;

  • 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度;

  • 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;

  • 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序;

进程间通信

由于多进程地址空间不同,数据不能共享,一个进程内创建的变量在另一个进程是无法访问的。,但是内核空间是每个进程共享的,所以进程之间要通信必须通过内核。于是操作系统提供了各种系统调用,搭建起各个进程间通信的桥梁,这些方法统称为进程间通信 IPC (IPC InterProcess Communication)。

常见进程间通信方式:

  • 匿名管道 Pipe:实质是一个内核缓冲区,进程以先进先出 FIFO 的方式从缓冲区存取数据。是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系(父子进程间)的进程间通信。

  • 命名管道 FIFO:提供了一个路径名与之关联,以文件形式存在于文件系统中,这样即使不存在亲缘关系的进程,只要可以访问该路径也能相互通信,支持同一台计算机的不同进程之间,可靠的、单向或双向的数据通信。

  • 消息队列 Message Queue:消息队列是存放在内核中的消息链表,每个消息队列由消息队列标识符表示,只有在内核重启或主动删除时,该消息队列才会被删除。

  • 共享内存 Shared memory:一个进程把地址空间的一段,映射到能被其他进程所访问的内存,一个进程创建、多个进程可访问,进程就可以直接读写这一块内存而不需要进行数据的拷贝,从而大大提高效率。(是最快的可用 IPC 形式,是针对其他通信机制运行效率较低而设计的)。

  • 信号 Signal:用于进程间互相通信或者操作的一种机制,可以在任何时候发给某一进程,无需知道该进程的状态。如果该进程当前不是执行态,内核会暂时保存信号,当进程恢复执行后传递给它。如果一个信号被进程设置为阻塞,则该信号的传递被延迟,直到其阻塞被取消时才被传递给进程。信号在用户空间进程和内核之间直接交互,内核可以利用信号来通知用户空间的进程发生了哪些系统事件,信号事件主要有两个来源:

    • 硬件来源:用户按键输入Ctrl+C退出、硬件异常如无效的存储访问等。

    • 软件终止:终止进程信号、其他进程调用 kill 函数、软件异常产生信号。

  • 套接字 Socket:( TCP/IP 协议栈,也是建立在 socket 通信之上),它是一种通信机制,凭借这种机制,既可以在本机进程间通信,也可以跨网络通过,因为,套接字通过网络接口将数据发送到本机的不同进程或远程计算机的进程。

管道

常用的Linux命令中的|就是一个管道(这样创建的管道是匿名管道,用完就销毁),功能是将前一个命令的输出作为后一个命令的输入,由此可见,管道传输数据是单向的,如果需要相互通信,就需要创建两个管道。

使用mkfifo命令可以创建命名管道,如下所示:

# 创建一个名叫myPipe的命名管道
mkfifo myPipe

# Linux中一切都是文件,所以这个命名管道也是文件
ls -l
prw-r--r--. 1 root    root         0 Jul 17 02:45 myPipe

# 向管道中写入数据
echo "hello" > myPipe # 此时管道处于阻塞状态,同步通信

# 从管道中读取数据
cat < myPipe  # 数据被消费后,上面阻塞的写操作就正常退出了
hello

由此可见,可以将命名管道理解为是一个无缓冲的队列,实现进程间的同步通信。

通过如下系统调用进行匿名管道的创建,所谓管道就是内核中的一段缓冲,从管道的一端写入的数据,实际上是缓存在内核中的,另一端读取,也就是从内核中读取这段数据。另外,管道传输的数据是无格式的字节流且大小受限。

// 返回两个文件描述符
// fd[0],表示管道的读取端
// fd[1],表示管道的写入端
int pipe(int fd[2])

上面的系统调用创建的这个管道的两个描述符都是在一个进程里面,并没有起到进程间通信的作用,怎么样才能使得管道是跨过两个进程的呢?

可以使用 fork() 创建子进程,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个fd[0]fd[1],两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信了。

管道只能一端写入,另一端读出,所以上面这种模式容易造成混乱,因为父进程和子进程都可以同时写入和读取。那么,为了避免这种情况,通常的做法是:

  • 父进程关闭读取的 fd[0],只保留写入的 fd[1];

  • 子进程关闭写入的 fd[1],只保留读取的 fd[0];

所以如果需要双向通信,则应该创建两个管道。

在来看看在shell中使用的|是如何实现的,比如执行A|B这个命令,那么,A 进程和 B 进程都是 shell 创建出来的子进程,A 和 B 之间不存在父子关系,它俩的父进程都是 shell。

所以说,在 shell 里通过|匿名管道将多个命令连接在一起,实际上也就是创建了多个子进程,在编写 shell 脚本时,能使用一个管道搞定的事情,就不要多用一个管道,这样可以减少创建子进程的系统开销。

消息队列

管道的通信方式是效率低的,因此管道不适合进程间频繁地交换数据,对于这个问题,消息队列的通信模式可以解决。

消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。

消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,而匿名管道的生命周期,是随进程的创建而建立,随进程的结束而销毁。

消息队列的不足:

  • 消息队列不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。在 Linux 内核中,会有两个宏定义 MSGMAXMSGMNB,它们以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度。

  • 消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。

共享内存

消息队列的读取和写入都会发生用户态与内核态之间的消息拷贝,对于这个问题,共享内存的通信模式可以解决。

现代操作系统,对于内存管理,采用的是虚拟内存技术,每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。即使进程A和进程B的虚拟地址是一样的,但访问的是不同的物理内存地址,对于数据的增删查改互不影响。

共享内存就是将进程虚拟地址空间中的某一部分,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。

信号量

共享内存的通信模式,带来了新的问题,如果多个进程同时修改同一个共享内存,就可能产生冲突。为了防止多进程竞争共享资源,而造成的数据错乱,需要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。信号量可以实现这种保护机制。

信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据

信号量表示资源的数量,初始值为1代表这互斥信号量,控制信号量的方式有两种原子操作,必须成对出现,可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存:

  • P操作(在进入共享资源之前):把信号量减去1,

    • 相减后如果信号量<0,则表明资源已被占用,进程需阻塞等待;

    • 相减后如果信号量>=0,则表明还有资源可使用,进程可正常继续执行。

  • V操作(在离开共享资源之后):把信号量加上1,

    • 相加后如果信号量<=0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;

    • 相加后如果信号量>0,则表明当前没有阻塞中的进程。

如下示例,进程A和B通过共享内存来通信:

  1. 进程 A 在访问共享内存前,先执行了 P 操作,由于信号量的初始值为 1,故在进程 A 执行 P 操作后信号量变为 0,表示共享资源可用,于是进程 A 就可以访问共享内存。

  2. 若此时,进程 B 也想访问共享内存,执行了 P 操作,结果信号量变为了 -1,这就意味着临界资源已被占用,因此进程 B 被阻塞。

  3. 直到进程 A 访问完共享内存,才会执行 V 操作,使得信号量恢复为 0,接着就会唤醒阻塞中的线程 B,使得进程 B 可以访问共享内存,最后完成共享内存的访问后,执行 V 操作,使信号量恢复到初始值 1。

信号量除了在共享内存场景下使用,也可以用来协调进程之间的依赖关系。

例如,进程 A 是负责生产数据,而进程 B 是负责读取数据,这两个进程是相互合作、相互依赖的,进程 A 必须先生产了数据,进程 B 才能读取到数据,所以执行是有前后顺序的。此时,就可以用信号量来实现多进程同步的方式,初始化信号量为 0。

  1. 如果进程 B 比进程 A 先执行了,那么执行到 P 操作时,由于信号量初始值为 0,故信号量会变为 -1,表示进程 A 还没生产数据,于是进程 B 就阻塞等待;

  2. 接着,当进程 A 生产完数据后,执行了 V 操作,就会使得信号量变为 0,于是就会唤醒阻塞在 P 操作的进程 B;

  3. 最后,进程 B 被唤醒后,意味着进程 A 已经生产了数据,于是进程 B 就可以正常读取数据了。

信号初始化为 0,就代表着是同步信号量,它可以保证进程 A 应在进程 B 之前执行。

信号

管道、消息队列、共享内存这些进程间通信,都是常规状态下的工作模式。对于异常情况下的工作模式,就需要用信号的方式来通知进程。

在 Linux 操作系统中, 为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。我们可以通过 kill -l 命令,查看所有的信号:

kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

在shell前台运行的进程,可以通过键盘输入某些组合键给进程发送信号。例如:

  • Ctrl+C 产生 SIGINT 信号,表示终止该进程;

  • Ctrl+Z 产生 SIGTSTP 信号,表示停止该进程,但还未结束;

如果进程在后台运行,可以通过 kill 命令的方式给进程发送信号,但前提需要知道运行中的进程 PID 号,例如:

  • kill -9 1050 ,表示给 PID 为 1050 的进程发送 SIGKILL 信号,用来立即结束该进程;

所以,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令)。

信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,就有下面这几种,用户进程对信号的处理方式。

  1. 执行默认操作。Linux 对每种信号都规定了默认操作:

    1. SIGTERM 信号,就是终止进程的意思。

    2. Core(Core Dump),即终止进程后,通过 Core Dump 将当前进程的运行状态保存在文件里面,方便事后进行分析。

  2. 捕捉信号。为信号定义一个信号处理函数,当信号发生时,就执行相应的信号处理函数。

  3. 忽略信号。不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILLSIGSTOP,它们用于在任何时候中断或结束某一进程。

套接字

管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通信,想跨网络与不同主机上的进程之间通信,就需要 Socket 通信了。实际上,Socket 通信不仅可以跨网络与不同主机的进程间通信,还可以在同主机上进程间通信。

通过如下系统调用来创建socket:

int socket(int domain, int type, int protocal)
// domain指定协议族
// AF_INET 用于 IPV4
// AF_INET6 用于 IPV6
// AF_LOCAL/AF_UNIX 用于本机

// type指定通信特性
// SOCK_STREAM 表示的是字节流,对应 TCP
// SOCK_DGRAM  表示的是数据报,对应 UDP
// SOCK_RAW 表示原始套接字

// protocal指定通信协议,已弃用一般写成 0,因为协议通过前面两个参数就指定了

根据创建 socket 类型的不同,通信的方式也就不同:

  • 实现 TCP 字节流通信:socket类型是 AF_INET 和 SOCK_STREAM;

  • 实现 UDP 数据报通信:socket类型是 AF_INET 和 SOCK_DGRAM;

  • 实现本地进程间通信:

    • 本地字节流socket类型是 AF_LOCAL 和 SOCK_STREAM

    • 本地数据报socket类型是 AF_LOCAL 和 SOCK_DGRAM

    • AF_UNIX 和 AF_LOCAL 是等价的,也属于本地 socket。

针对 TCP 协议通信的 socket 编程模型

  1. 服务端和客户端初始化 socket,得到文件描述符;

  2. 服务端调用bind,将绑定在 IP 地址和端口;

  3. 服务端调用listen,进行监听;

  4. 服务端调用accept,等待客户端连接;

  5. 客户端调用connect,向服务器端的地址和端口发起连接请求;

  6. 服务端accept返回用于传输的 socket 的文件描述符;

  7. 客户端调用write写入数据;服务端调用read读取数据;

  8. 客户端断开连接时,会调用close,那么服务端read读取数据的时候,就会读取到了EOF,待处理完数据后,服务端调用close,表示连接关闭。

注意,服务端调用accept时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。所以,监听的 socket 和真正用来传送数据的 socket,是两个socket,一个叫作监听socket,一个叫作已完成连接socket

成功连接建立之后,双方开始通过readwrite函数来读写数据,就像往一个文件流里面写东西一样。

针对 UDP 协议通信的 socket

UDP 是没有连接的(不需要三次握手,不需要像 TCP 调用 listen 和 connect),但是 UDP 的交互仍然需要 IP 地址和端口号,因此需要 bind。

对于 UDP 来说,不需要要维护连接,没有所谓的发送方和接收方,甚至都不存在客户端和服务端的概念,只要有一个 socket 多台机器就可以任意通信,因此每一个 UDP 的 socket 都需要 bind。每次通信时,调用 sendtorecvfrom,都要传入目标主机的 IP 地址和端口。

针对本地进程间通信的 socket 编程模型

本地 socket 被用于在同一台主机上进程间通信的场景:

  • 本地 socket 的编程接口和 IPv4 、IPv6 套接字编程接口是一致的,可以支持「字节流」和「数据报」两种协议;

  • 本地 socket 的实现效率大大高于 IPv4 和 IPv6 的字节流、数据报 socket 实现;

对于本地字节流 socket,其 socket 类型是 AF_LOCAL 和 SOCK_STREAM。

对于本地数据报 socket,其 socket 类型是 AF_LOCAL 和 SOCK_DGRAM。

本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别。

线程

线程分类

  • 系统级线程(Kernal Thread):系统会帮助我们自动地创建和销毁系统级的线程(操作系统提供的线程)

  • 用户级线程(User Thread):架设在系统级线程之上,由用户(我们编写的程序)完全控制的代码执行流程,用户级线程的创建、销毁、调度、状态变更以及其中的代码和数据都完全需要程序自己去实现和处理

    • 优势:用户级线程的创建和销毁不通过操作系统,速度快,不用等待操作系统去调度它们运行,所以容易控制且灵活

    • 劣势:复杂,如果只是用系统级线程,那么只要指明需要新线程执行的代码片段,并且下达创建或销毁线程的指令就好,其他的具体实现由操作系统代劳。我们必须全权负责与用户级线程相关的所有具体的实现,并且需要和操作系统争取对接,否则可能无法正确运行

  • 轻量级进程(Lightweight Process):在内核中来支持用户线程

用户线程和内核线程的对应关系:

  • 多个用户线程对应同一个内核线程

  • 一个用户线程对应一个内核线程

  • 多个用户线程对应到多个内核线程

用户线程

用户线程是基于用户态的线程管理库来实现的,那么线程控制块(Thread Control Block, TCB) 也是在库里面来实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB。

所以,用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等

用户线程的优点:

  • 每个进程都需要有它私有的线程控制块(TCB)列表,用来跟踪记录它各个线程状态信息(PC、栈指针、寄存器),TCB 由用户级线程库函数来维护,可用于不支持线程技术的操作系统

  • 用户线程的切换也是由线程库函数来完成的,无需用户态与内核态的切换,所以速度特别快

用户线程的缺点:

  • 由于操作系统不参与线程的调度,如果一个线程发起了系统调用而阻塞,那进程所包含的用户线程都不能执行了

  • 当一个线程开始运行后,除非它主动地交出 CPU 的使用权,否则它所在的进程当中的其他线程无法运行,因为用户态的线程没法打断当前运行中的线程,它没有这个特权,只有操作系统才有,但是用户线程不是由操作系统管理的

  • 由于时间片分配给进程,故与其他进程比,在多线程执行时,每个线程得到的时间片较少,执行会比较慢

内核线程

内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责

内核线程的优点:

  • 在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行

  • 分配给线程,多线程的进程获得更多的 CPU 运行时间

内核线程的缺点:

  • 在支持内核线程的操作系统中,由内核来维护进程和线程的上下文信息,如 PCB 和 TCB

  • 线程的创建、终止和切换都是通过系统调用的方式来进行,因此对于系统来说,系统开销比较大

轻量级进程

轻量级进程(Light-weight process,LWP)是内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持。

  • 1 : 1 模式:一个线程对应到一个 LWP 再对应到一个内核线程,如上图的进程 4,属于此模型

    • 优点:实现并行,当一个 LWP 阻塞,不会影响其他 LWP

    • 缺点:每一个用户线程,就产生一个内核线程,创建线程的开销较大

  • N : 1 模式:多个用户线程对应一个 LWP 再对应一个内核线程,如上图的进程 2,线程管理是在用户空间完成的,此模式中用户的线程对操作系统不可见

    • 优点:用户线程要开几个都没问题,且上下文切换发生用户空间,切换的效率较高

    • 缺点:一个用户线程如果阻塞了,则整个进程都将会阻塞,另外在多核 CPU 中,是没办法充分利用 CPU 的

  • M : N 模式:根据前面的两个模型混搭一起,就形成 M:N 模型,该模型提供了两级控制,首先多个用户线程对应到多个 LWP,LWP 再一一对应到内核线程,如上图的进程 3

    • 优点:综合了前两种优点,大部分的线程上下文发生在用户空间,且多个线程又可以充分利用多核 CPU 的资源

  • 组合模式:如上图的进程 5,此进程结合 1:1 模型和 M:N 模型。可以针对不同的应用特点调节内核线程的数目来达到物理并行性和逻辑并行性的最佳方案

线程与进程的比较

  • 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位

  • 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈

  • 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系

  • 线程能减少并发执行的时间和空间开销

对于,线程相比进程能减少开销,体现在:

  • 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们

  • 线程的终止时间比进程快,因为线程释放的资源相比进程少很多

  • 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的

  • 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了

所以,线程比进程不管是时间效率,还是空间效率都要高。

所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。

对于线程和进程,我们可以这么理解:

  • 当进程只有一个线程时,可以认为进程就等于线程

  • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的

另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。

硬件设备访问速度的差异

CPU、内存和I/O设备之间速度差异:

访问时间

设备类型

容量

1ns

寄存器

1KB

2ns

高速缓存

4MB

10ns

主存

16GB

10ms

磁盘

4TB

解决访问速度差异的方式:

  1. CPU 使用缓存来中和与内存的访问速度差异

  2. 操作系统提供进程和线程调度,让 CPU 在执行指令的同时分时复用线程,让内存和磁盘不断交互,不同的 CPU 时间片能够执行不同的任务,从而均衡这三者的差异

  3. 编译程序提供优化指令的执行顺序,让缓存能够合理的使用

并行与并发

  • 并发:CPU分时执行多个任务(指令),切换任务前把没完成的当前任务的状态暂存起来

  • 并行:多个CPU同时处理多个任务(指令)

合理使用线程是一门艺术,合理编写一道准确无误的多线程程序更是一门艺术。

多线程安全问题

在没有采用同步机制的情况下,多个线程中的执行操作往往是不可预测的。

可见性问题

  1. 单核时代,所有的线程共用一个 CPU,CPU 缓存和内存的一致性问题容易解决。

  2. 多核时代,每个核都独立的运行一个线程,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。

原子性问题

如果对一个变量的操作分为多个步骤,多线程执行的过程中,无论是并发还是并行,由于可见性问题,线程单独操作变量,并将结果写回内存,将会导致变量结果与预期不一致。

因此在多线程中,需要保证对变量的操作是原子性的,即这个操作是一个原子操作(要么全部执行,要么全部不执行)。

原子性操作是完全独立于任何其他进程运行的操作,原子操作多用于现代操作系统和并行处理系统中。原子操作通常在内核中使用,因为内核是操作系统的主要组件。但是,大多数计算机硬件,编译器和库也提供原子性操作。

有序性问题

编译器有时候确实是 「好心办坏事」,它为了优化系统性能,往往更换指令的执行顺序。

活跃性问题

死锁:每个线程都在等待其他线程释放资源,而其他资源也在等待每个线程释放资源,这样没有线程抢先释放自己的资源,产生死锁无线等待下去。

四个必要条件,破坏一个就不会死锁:

  • 互斥:资源排他性使用

  • 请求和保持:已持有资源的线程请求新资源(新资源处于占用状态),请求阻塞

  • 不剥夺:已占用的资源只能由占用者释放

  • 循环等待

活锁:两个并行线程尝试获取另一个锁失败后,释放自己持有的锁,过程一直重复,虽然没有线程阻塞,但任务没有向下执行。

多线程安全问题解决方案

当多个线程共享资源,即同时对一共享数据进行修改,从而影响程序运行的正确性时,就称为竞态条件。

线程安全的核心是对状态访问操作进行管理,只要共享变量或变量状态可变就会出现问题。

  1. 采用同步机制

  2. 不在多线程中共享变量且将变量设置为不可变

同步机制

  1. 解决原子性:互斥锁、读写锁、自旋锁、条件变量、信号量

  2. 解决可见性:volatile

多线程性能问题

在并发场景下,线程切换(上下文切换)这个操作开销很大,把大量的时间消耗在线程切换上而不是线程运行上。

线程上下文切换时,要看它们是不是属于同一个进程:

  • 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样

  • 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据

context:上下文切换的资源,寄存器的状态,程序计数器,栈等。

切换过程包括:

  1. 暂停当前线程

  2. 保存当前状态

  3. 选择合适线程

  4. 加载新的状态

  5. 执行线程代码

引起切换的原因:

  1. 当前正在执行的任务完成,系统的 CPU 正常调度下一个需要运行的线程

  2. 当前正在执行的任务遇到 I/O 等阻塞操作,线程调度器挂起此任务,继续调度下一个任务

  3. 多个任务并发抢占锁资源,当前任务没有获得锁资源,被线程调度器挂起,继续调度下一个任务

  4. 用户的代码挂起当前任务,比如线程执行 sleep() 方法,让出CPU

  5. 使用硬件中断的方式引起上下文切换

最后更新于

这有帮助吗?