前言
本文为 《图解系统》系列文章的个人学习笔记,对具体知识点与示例进行了归纳整理,详细内容参考小林coding 。
一、进程 & 线程基础知识
1.1 进程
A. 进程的状态
进程有着「运行 - 暂停 - 运行」的活动规律。一般说来,一个进程并不是自始至终连续不停地运行的,它与并发执行中的其他进程的执行是相互制约的。
所以,在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。此外,进程还有创建状态、结束状态两个基本状态。
创建状态(new):进程正在被创建时的状态。
运行状态(Running):该时刻进程占用 CPU。
就绪状态(Ready):可运行,由于其他进程处于运行状态而暂时停止运行。
阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行。
结束状态(Exit):进程正在从系统中消失时的状态。
于是,一个完整的进程状态的变迁如下图:
如果有大量处于阻塞状态的进程,进程可能会占用着物理内存空间。所以,在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次运行的时候,再从硬盘换入到物理内存。
对于进程没有占用实际的物理内存空间的情况,这个状态就是挂起状态。挂起状态又可以分为两种:
阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现。
就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行。
此外,还可以通过 sleep()
让进程间歇性挂起,其工作原理是设置一个定时器,到期后唤醒进程。
B. 进程的控制结构
在操作系统中,是用**进程控制块(process control block,PCB)**数据结构来描述进程的。PCB 是进程存在的唯一标识,其中包含的信息有:
(1)进程描述信息:包含进程唯一标识符、归属用户标识符。
(2)进程控制和管理信息:包含进程当前状态、进程优先级。
(3)资源分配清单:有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息。
(4)CPU 相关信息:CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中。
PCB 通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。例如,把所有因等待某事件而处于等待状态的进程链在一起就组成各种阻塞队列。就绪队列和阻塞队列链表的组织形式如下图:
C. 进程的控制
创建进程
操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源。
创建进程的过程如下:
- 申请一个空白的 PCB,并向 PCB 中填写一些控制和管理进程的信息,比如进程的唯一标识等;
- 为该进程分配运行时所必需的资源,比如内存资源(子进程得到与父进程用户级虚拟地址相同的但独立的一份副本);
- 将 PCB 插入到就绪队列,等待被调度运行。
终止进程
进程可以有 3 种终止方式:正常结束、异常结束以及外界干预(信号
kill
掉)。
终止进程的过程如下:
- 查找需要终止的进程的 PCB;
- 如果处于执行状态,则立即终止该进程的执行,然后将 CPU 资源分配给其他进程;
- 如果其还有子进程,则应将该进程的子进程交给 1 号进程接管;
- 将该进程所拥有的全部资源都归还给操作系统;
- 将其从 PCB 所在队列中删除。
当子进程被终止时,其在父进程处继承的资源应当还给父进程。而当父进程被终止时,该父进程的子进程就变为孤儿进程,会被 1 号进程收养,并由 1 号进程对它们完成状态收集工作。(父子进程概念辨析参考文章【父进程与子进程】中给出的示例)
阻塞进程
当进程需要等待某一事件完成时,它可以调用阻塞语句把自己阻塞等待。而一旦被阻塞等待,它只能由另一个进程唤醒。
阻塞进程的过程如下:
- 找到将要被阻塞进程标识号对应的 PCB;
- 如果该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行;
- 将该 PCB 插入到阻塞队列中去。
唤醒进程
进程由「运行」转变为「阻塞」状态是由于进程必须等待某一事件的完成,所以处于阻塞状态的进程是绝对不可能叫醒自己的。如果某进程正在等待 I/O 事件,则只有当该进程所期待的事件出现时,才由发现者进程用唤醒语句叫醒它。
唤醒进程的过程如下:
- 在该事件的阻塞队列中找到相应进程的 PCB;
- 将其从阻塞队列中移出,并置其状态为就绪状态;
- 把该 PCB 插入到就绪队列中,等待调度程序调度。
D. 进程的上下文切换
各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执行。这个一个进程切换到另一个进程运行,称为进程的上下文切换。
(1) 什么是 CPU 上下文?
大多数操作系统都是多任务,通常支持大于 CPU 数量的任务同时运行。任务是交给 CPU 时,需要告诉 CPU 任务从哪里加载,又从哪里开始运行。因此,操作系统需要事先帮 CPU 设置好 CPU 寄存器和程序计数器。
所以说,CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下文。
(2)CPU 上下文切换的具体过程
具体来说,CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
系统内核会存储保持下来的上下文信息,当此任务再次被分配给 CPU 运行时,CPU 会重新加载这些上下文,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。
上面说到所谓的「任务」,主要包含进程、线程和中断。所以,可以根据任务的不同,把 CPU 上下文切换分成:进程上下文切换、线程上下文切换和中断上下文切换。
(3)进程的上下文切换到底切换了什么?
进程是由内核管理和调度的,所以进程的切换只能发生在内核态。所以,进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中。
需要注意,进程的上下文开销是很关键的,我们希望它的开销越小越好,这样可以使得进程可以把更多时间花费在执行程序上,而不是耗费在上下文切换。
(4)发生进程上下文切换有哪些场景?
- 时间片耗尽
为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。当某个进程的时间片耗尽,进程就从运行状态变为就绪状态,系统从就绪队列选择另外一个进程运行。- 系统资源不足
进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行。- 睡眠函数
当进程通过睡眠函数sleep
这样的方法将自己主动挂起时,自然也会重新调度。- 高优先级进程
当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行。- 硬件中断
发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序。
以上,就是发生进程上下文切换的常见场景了。
1.2 线程
A. 什么是线程?
线程是进程当中的一条执行流程。
同一个进程内多个线程之间可以共享代码段、堆空间、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈,这样可以确保线程的控制流是相对独立的。
B. 线程的状态和控制结构
线程同样具有创建、就绪、阻塞、执行、结束五种基本状态,同样具有状态之间的转换关系。
与进程类似,系统也为每个线程配置一个线程控制块(thread control block,TCB),用于记录控制和管理线程的信息。线程控制块通常包括:
(1)线程描述信息:线程标识符。
(2)线程控制和管理信息:线程当前状态、线程优先级。
(3)资源分配清单:线程专有存储区,例如栈入口指针。
(4)CPU 相关信息:CPU 中各个寄存器的值,当线程被切换时,CPU 的状态信息都会被保存在相应的TCB 中。
C. 线程的上下文切换
- 两个线程不是属于同一个进程
不同进程的线程上下文切换过程就跟进程上下文切换一样。- 两个线程是属于同一个进程
因为虚拟内存是共享的,所以在线程上下文切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。
所以,线程的上下文切换相比进程,开销要小很多。
D. 线程与进程的比较
线程与进程的比较如下:
- 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位;
- 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;
- 线程能减少并发执行过程中的时间开销和空间开销;
线程相比进程能减少开销,体现在:
- 线程创建/终止更快(对应的资源更少)
线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
线程的终止时间比进程快,因为线程释放的资源相比进程少很多;- 同一进程内的线程切换快(无需页表切换)
同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;- 同一进程的各线程间通信效率更高(无需经过内核)
由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了;
综上,不管是时间效率,还是空间效率线程比进程都要高。但线程有一个缺点:当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃(仅针对 C/C++ 语言,Java没有该问题,具体分析原因见:线程崩溃了,进程也会崩溃吗?)。
E. 线程的实现
主要有三种线程的实现方式:
用户线程(User Thread): 在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理。
内核线程(Kernel Thread): 在内核中实现的线程,是由内核管理的线程。
轻量级进程(LightWeight Process): 在内核中来支持用户线程。
实际上,无论是创建进程的fork
,还是创建线程的pthread_create
,底层实现都是调用同一个内核函数clone
。
1.)如果复制对方的地址空间,那么就产出一个“进程”,
2)如果共享对方的地址空间,就产生一个“线程”
Linux内核是不区分进程和线程的,只在用户层面上进行区分。所以,线程所有操作函数pthread*
是库函数,而非系统调用。
二、进程间 & 线程间的通信方式
2.1 进程间通信
进程间通信方式包括:管道(匿名管道pipe、命名管道fifo),消息队列、信号量、共享内存、信号、套接字、
(1)管道
对于匿名管道(pipe),它的通信范围是存在父子关系(或属于同一父进程)的进程。因为管道没有实体,也就是没有管道文件,只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的。
Shell实现匿名管道
[root@test ~] A | B
系统调用实现匿名管道
int pipefd[2];
if(pipe(pipefd) == -1) exit(EXIT_FAILURE);
pid_t pid = fork(); // 创建子进程
if (pid < 0) { // fork 失败
exit(EXIT_FAILURE);
} else if (pid == 0) { // 子进程
close(pipefd[1]); //关闭写入端,会触发一个信号槽机制,告知读取端可以读数据了
if(read(pipefd[0],buff,sizeof(buff)) == -1){
perror("read failed");
exit(EXIT_FAILURE);
}
} else { // 父进程
close(pipefd[0]); //关闭读取端,会触发一个信号机制,与上面同理
char buff[20]="hello,pipe";
if(write(pipefd[1],buff,sizeof(buff) == -1){
perror("write failed");
exit(EXIT_FAILURE);
}
}
对于命名管道,它可以在不相关的进程间也能相互通信。因为命令管道,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。
(2)消息队列
消息队列实际上是保存在内核的「消息链表」,消息队列的消息体是可以用户自定义的数据类型(收发双方对齐)。
消息队列通信的速度不是最及时的,毕竟每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程。
(3)共享内存
共享内存可以解决消息队列通信中用户态与内核态之间数据拷贝过程带来的开销。
共享内存的机制,就是拿出一块虚拟地址空间,映射到相同的物理内存中。它直接分配一个共享空间,挂载到每个进程虚拟内存的文件映射段,每个进程都可以直接访问,就像访问进程自己的空间一样快捷方便,不需要陷入内核态或者系统调用,大大提高了通信的速度。
但是,多个进程共享同一块物理内存,进一步引入了进程间的互斥和同步问题。
(4)信号量
信号量可以实现不同进程/线程间的互斥与同步。
信号量操作
信号量表示资源的数量,控制信号量的方式有 P 操作(Proberen,荷兰语,意为尝试)和 V操作(Verhogen,荷兰语,意为增加)两种原子操作:
P 操作
:这个操作会把信号量减去1,相减后如果信号量<0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量>=0,则表明还有资源可使用,进程可正常继续执行。V操作
:这个操作会把信号量加上1,相加后如果信号量<=0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量>0,则表明当前没有阻塞中的进程。
互斥的实现
信号量初始化为 1
,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。
例如,如果要使得 A、B 两个进程互斥访问共享内存,则具体的过程如下:
Step 1. 进程 A 在访问共享内存前,先执行了 P 操作,由于信号量的初始值为 1,故在进程 A 执行 P操作后信号量变为 0,表示共享资源可用,于是进程 A 就可以访问共享内存。
Step 2. 若此时,进程 B也想访问共享内存,执行了P操作,结果信号量变为了 -1,这就意味着临界资源已被占用,因此进程 B 被阻塞。
Step 3. 直到进程 A 访问完共享内存,才会执行 V 操作,使得信号量恢复为 0,接着就会唤醒阻塞中的线程 B,使得进程 B 可以访问共享内存,最后完成共享内存的访问后,执行 V 操作,使信号量恢复到初始值 1。
同步的实现
信号量初始化为 0
,就代表着是同步信号量,它可以保证进程 A 应在进程 B 之前执行。
例如,进程 A 是负责生产数据,而进程 B 是负责读取数据,这两个进程是相互合作、相互依赖的,进程 A 必须先生产了数据,进程 B 才能读取到数据,所以执行是有前后顺序的。具体过程:
Step 1. 如果进程 B 比进程 A 先执行了,那么执行到 P 操作时,由于信号量初始值为 0,故信号量会变为 -1,表示进程 A 还没生产数据,于是进程 B 就阻塞等待。
Step 2. 接着,当进程 A 生产完数据后,执行了 V 操作,就会使得信号量变为 0,于是就会唤醒阻塞在 P 操作的进程 B。
Step 3. 最后,进程 B 被唤醒后,意味着进程 A 已经生产了数据,于是进程 B 就可以正常读取数据了。
(5)信号
对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。
[root@test ~]# 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 命令的方式给进程发送信号,例如
- kill -9 1050 ,表示给 PID 为 1050 的进程发送
SIGKILL
信号,用来立即结束该进程;
所以,信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill 命令)。
信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种用户进程对信号的处理方:
1.执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号就是终止进程的意思。
2.捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。
3.忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即SIGKILL和 SEGSTOP,它们用于在任何时候中断或结束某一进程。
(6)套接字(Socket)
前面提到的管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通信,要想跨网络与不同主机上的进程之间通信,就需要 Socket 通信了。
Socket 实际上不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信,可根据创建 Socket 的类型不同,分为三种常见的通信方式,一个是基于 TCP 协议的通信方式,一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。(具体内容参考计算机网络)
2.2 线程间通信
线程间通信方式包括:信号、锁机制、条件变量、信号量。
(1)信号量
与进程相同。
(2)信号
与进程相同。
(3)锁机制
互斥锁、读写锁、自旋锁
(4)条件变量
同一个进程内多个线程之间可以共享数据段、堆空间,因此这些线程间可以通过位于数据段和堆段的变量(如全局变量)进行通信。
三、多线程冲突了怎么办?(锁与信号量)
3.1 竞争与协作
A. 竞争 - 互斥
当多线程/进程相互竞争操作共享变量时,若在一个线程/进程的执行过程中发生了上下文切换(执行中间状态保留在 CPU 寄存器,未返回 Cache/内存),就会出现错误结果,且输出的结果存在不确定性。
由于多线程/进程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为临界区(critical section),它是访问共享资源的代码片段,一定不能给多线程同时执行。
我们希望这段代码是互斥(mutualexclusion)的,即保证一个线程/进程在临界区执行时,其他线程/进程应该被阻止进入临界区。
B. 协作 - 同步
有时候我们希望多个线程能密切合作,以实现一个共同的任务。例如,线程 1 是负责读入数据的,而线程 2 是负责处理数据的,这两个线程是相互合作、相互依赖的。线程 2 在没有收到线程 1 的唤醒通知时,就会一直阻塞等待,当线程 1 读完数据需要把数据传给线程 2 时,线程 1 会唤醒线程 2,并把数据交给线程 2 处理。
所谓同步,就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步。
综上,可以对同步与互斥两个进行总结:
- 同步:「操作 A 应在操作 B 之前执行」,操作 C 必须在操作 A 和操作 B 都完成之后才能执行」等。
- 互斥:「操作 A 和操作 B 不能在同一时刻执行」。
3.1 互斥与同步的实现
为了实现进程/线程间正确的协作,操作系统提供的主要方法有两种: 锁(互斥)、 信号量(互斥、同步)。
A. 锁
锁的互斥实现
使用加锁操作和解锁操作可以解决并发线程/进程的互斥问题。
任何想进入临界区的线程,必须先执行加锁操作。若加锁操作顺利通过,则线程可进入临界区;在完成对临界资源的访问后再执行解锁操作,以释放该临界资源。
锁的类型
根据锁的实现不同,可以分为「忙等待锁」和「无忙等待锁」。
- 「忙等待锁」:当获取不到锁时,线程就会一直 while循环,不做任何事情,所以就被称为忙等待锁,也被称为自旋锁(spin lock)。
- 「无忙等待锁」:当没获取到锁的时候,就把当前线程放入到锁的等待队列,然后执行调度程序,把 CPU 让给其他线程执行。
B. 信号量
信号量的 PV 操作
信号量是操作系统提供的一种协调共享资源访问的方法。通常信号量表示资源的数量,对应的变量是一个整型(sem
)变量。
控制信号量的方式有 P 操作(Proberen,荷兰语,意为尝试)和 V操作(Verhogen,荷兰语,意为增加)两种原子操作:
- P 操作:将
sem
减1
,如果sem < 0
,则进程/线程进入阻塞等待,否则继续。(对应sem_wait()
) - V 操作:将
sem
加1
,相加后,如果sem <= 0
,唤醒一个等待中的进程/线程。(对应sem_post()
)
信号量的互斥实现
1)初始化:为共享资源设置一个信号量 s
,其初值为 1
,表示该临界资源未被占用。
2)互斥操作:只要把进入临界区的操作置于 P(s)
和 V(s
) 之间,即可实现进程/线程互斥:
信号量的同步实现
1)初始化:同步的方式是设置一个信号量 s
,其初值为 0
。
2)同步操作:生产线程在完成处理后 P(s)
,消费线程在进行处理前 V(s)
。
四、有哪些琐?什么是悲观锁、乐观锁?
4.1 互斥锁与自旋锁
两种锁的区别
当已经有一个线程加锁后,其他线程加锁则就会失败。互斥锁和自旋锁对于加锁失败后的处理方式是不一样的:
- 互斥锁加锁失败后,线程会释放 CPU ,给其他线程(线程切换)。
- 自旋锁加锁失败后,线程会忙等待,直到它拿到锁。
互斥锁的优缺点
-
优点
互斥锁在加锁失败后会释放 CPU,可以减少 CPU 占用 -
缺点
互斥锁加锁失败时,会用「线程切换」来应对(当前线程陷入内核态被阻塞),当加锁失败的线程再次加锁成功后的这一过程,会有两次线程上下文切换的成本,性能损耗比较大。
自旋锁的优缺点
-
优点
自旋锁加锁失败时,并不会主动产生线程切换,而是一直忙等待,直到获取到锁,可以避免线程上下文切换产生的开销。 -
缺点
若被锁住的代码执行时间过长,自旋的线程会长时间占用 CPU 资源。
互斥锁和自旋锁的选择
若被锁住的代码执行时间很短应该选用自旋锁,否则使用互斥锁。
4.2 读写锁
读写锁由「读锁」和「写锁」两部分构成,如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。
读写锁的工作原理是:
当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
当「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。
所以说,写锁是独占锁,因为任何时刻只能有一个线程持有写锁;而读锁是共享锁,因为读锁可以被多个线程同时持有。我们可以发现,读写锁在读多写少的场景,能发挥出优势。
另外,根据实现的不同,读写锁可以分为「读优先锁」、「写优先锁」以及「公平读写锁」,在此不再赘述。
4.3 乐观锁与悲观锁
前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁。悲观锁认为多线程同时修改共享资源并出现冲突的概率比较高,所以访问共享资源前,先要上锁。
乐观锁假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。【先修改,有冲突再处理】
由此可见,乐观锁全程并没有加锁,所以它也叫无锁编程。
乐观锁的典型案例有:共享文档、Git、SVN等。乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。
五、怎么避免死锁?
5.1 死锁的概念
死锁问题的产生是由两个或者以上线程并行执行的时候,争夺资源而互相等待造成的。
当两个线程为了保护两个不同的共享资源而使用了两个互斥锁,那么这两个互斥锁应用不当的时候,可能会造成两个线程都在等待对方释放锁,在没有外力的作用下,这些线程会一直相互等待,这种情况就是发生了死锁。
死锁只有同时满足以下四个条件才会发生:
- 互斥条件;
- 持有并等待条件;
- 不可剥夺条件;
- 环路等待条件;
A. 互斥条件
互斥条件是指多个线程不能同时使用同一个资源。
B. 持有并等待条件
持有并等待条件是指,当线程 A 已经持有了资源 1,又想申请资源 2,而资源 2 已经被线程 C 持有了,所以线程 A 就会处于等待状态,但是线程 A 在等待资源 2 的同时并不会释放自己已经持有的资源 1。
C. 不可剥夺条件
不可剥夺条件是指,当线程已经持有了资源 ,在自己使用完之前不能被其他线程获取,线程 B 如果也想使用此资源,则只能在线程 A 使用完并释放后才能获取。
D. 环路等待条件
环路等待条件指的是,在死锁发生的时候,两个线程获取资源的顺序构成了环形链。
5.2 避免死锁问题的发生
要避免死锁问题,就是要破坏其中一个条件即可,最常用的方法就是使用资源有序分配法来破坏环路等待条件。
相关问题
并发和并行的区别
一个进程最多可以创建多少个线程?
- 32 位系统,用户态的虚拟空间只有 3G,如果创建线程时分配的栈空间是 10M,那么一个进程最多只能创建 300 个左右的线程。
- 64 位系统,用户态的虚拟空间大到有 128T理论上不会受虚拟内存大小的限制,而会受系统的参数或性能限制。