二、进程与线程
1. 进程、线程比较
问:为什么需要进程? 实现多道程序程序的并发。
问:进程控制块? Process Control Block,PCB。系统利用PCB来描述进程的基本情况和运行态,进而控制和管理进程。
问:进程映像(进程实体)? 由程序段、相关数据段、PCB三部分构成了进程映像。所谓创建进程,实质上是创建进程映像中的PCB;而撤销进程,实质上是撤销进程的PCB。 进程映像是静态的,而进程则是动态的。
进程的定义? 从不同的角度,进程可以有不同的定义,比较典型的定义有: 1)进程是程序的一次执行过程。 2)进程是一个程序及其数据在处理机上顺序执行时所发生的活动。 3)进程是具有独立功能的程序在一个数据集合上运行的过程,它是系统进程资源分配和调度的一个独立单位。 引入进程实体的概念后,我们可把传统操作系统中的进程定义为:进程是进程实体的运行过程,是系统进程资源分配和调度的一个独立单位。
问:进程的特征?(不重要) 1. 动态性 2. 并发性 3. 独立性 4. 异步性 5. 结构性
(1)进程、线程的概念?
1. 进程本质上是正在执行的一个程序,是系统进行资源调度和分配的基本单位,实现了操作系统的并发。 2. 线程是进程的子任务,是CPU调度和分派的基本单位,用于保证程序的实时性,实现进程内部的并发; //线程是操作系统可识别的最小执行和调度单位。 //每个线程都独自占用一个虚拟处理器:独自的寄存器组,指令计数器和处理器状态。 //每个线程完成不同的任务,但是共享同一地址空间(同样的动态内存、映射文件、目标代码),打开的文件队列和其他内核资源。
(2)进程、线程的区别?多进程、多线程的区别?
进程、线程的区别: 1. 进程是CPU资源分配的最小单位;线程是CPU调度的最小单位 2. 一个线程只能属于一个进程;而一个进程可以有多个线程,但至少有一个线程。线程依赖进程而存在。 3. 进程有独立的系统资源,而同一进程内的线程共享进程的大部分系统资源,包括堆、代码段、数据段;每个线程只拥有一些在运行中必不可少的私有属性,如TCB、线程ID、栈、寄存器。 4. 系统开销: 在创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O设备等,因此开销较大; 而线程切换只需保存和设置少量的寄存器内容,并不涉及存储器管理方面的操作。 5. 通信:进程通信比较复杂;而同一进程的线程由于共享代码段和数据段,所以线程通信比较方便。 //由于同一进程中的多个线程具有相同的地址空间,致使它们之间的同步通信的实现比较容易。 //线程的切换、同步和通信都无需操作系统内核的干预。 6. 进程间不会相互影响;一个线程挂掉将导致同一进程内的其他线程也挂掉。 //多进程程序更加安全,进程间互不影响;多线程程序不易维护,线程间相互影响 7. 进程适用于多核、多机分布;线程适用与多核
多进程、多线程的区别: 1. 多进程中数据是分离的,这样共享复杂,同步简单; 多线程中数据是共享的,这样共享简单,同步复杂 2. 进程创建、销毁和切换比较复杂,速度较慢; 线程创建、销毁和切换比较简单,速度较快 3. 进程占用内存多,CPU利用率低; 线程占用内存少,CPU利用率高 4. 多进程的编程和调试比较简单; 多线程的编程和调试比较复杂 5. 进程间不同相互影响; 一个线程挂掉将导致整个进程挂掉 6. 多进程适用于多核、多机分布; 多线程适用于多核分布 两个答案其实都一样,记住一个。
问:相比于进程,线程的优势有哪些? 1. 系统开销(空间和空间方面):进程创建和切换比线程的开销大 由于在创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O设备等,因此开销较大; 而线程切换只需保存和设置少量的寄存器内容,并不涉及存储器管理方面的操作。 2. 通信:进程通信复杂,线程通信方便。 由于同一进程中的多个线程具有相同的地址空间,致使它们之间的同步通信的实现比较容易。 线程的切换、同步和通信都无需操作系统内核的干预。
(4)有了进程,为什么还要线程?
引入进程的目的是为了更好地使多道程序并发执行,提高资源利用率和系统吞吐量,增加并发程序; 引入线程的目的是为了减少程序在并发执行时所付出的时空开销,提高操作系统的并发性能。 //上面的答案也可以回答这题。 线程产生的原因: 进程可以使多个程序能并发执行,以提高资源的利用率和系统的吞吐量;但是其具有一些缺点。 1. 进程在同一时间只能干一件事。 2. 进程在执行的过程中如果阻塞,整个进程就会挂起,//即使进程中有些工作不依赖于等待的资源,仍然不会执行。 因此,操作系统引入比进程粒度更小的线程,作为并发执行的基本单位,从而减少程序在并发执行时所付出的时空开销,提高并发性。
(5)线程的优缺点
优点: 1. 一个进程中可以同时存在多个线程 2. 各个线程之间可以并发执行 3. 各个线程之间可以共享地址空间和文件等资源 缺点: 1. 当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃
(6)为什么线程相比进程能减少开销?
1. 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息; 进程在创建的过程中,不会涉及这些资源管理信息,而是共享它们 2. 线程的终止时间比进程快,因为线程释放的资源相比进程少很多 3. 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间,这意味着同一进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。 而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的 4. 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了 所以,不管是时间效率还是空间效率线程都比进程高
(7)多进程、多线程之间如何选择?
* 多进程适用场景:弱相关的任务,需要扩展到多机分布的任务 * 多线程适用场景:需要频繁创建和销毁的任务(如Web服务器),需要进行大量计算的任务,强相关的任务,需要扩展到多核分布的任务 多线程模型主要优势在于线程间切换代价较小, 1. 适用于I/O密集型的工作场景;//因此I/O密集型的工作场景经常由于I/O阻塞导致频繁的切换线程。 2. 适用于单机多核分布式场景。 多进程模型: 1. 适用于CPU密集型 2. 适用于多机分布式场景,//易于多机扩展
(8)游戏服务器应该为每个用户开辟一个线程还是一个进程,为什么?
游戏服务器应该为每个用户开辟一个进程。因为同一进程间的线程会相互影响,一个线程死掉会影响其他线程,从而导致进程崩溃。因此为了保证不同用户之间不会相互影响,应该为每个用户开辟一个进程。
2. 进程详解
1. 进程的组成部分有哪些?
进程由三部分组成:PCB、程序段、数据段 1. PCB PCB是进程实体的一部分,是进程存在的唯一标志。 //创建一个进程时,系统为该进程建立一个PCB;进程执行时,系统通过其PCB了解进程的现行状态信息,以便对其进程控制和管理;进程结束时,系统收回其PCB,该进程随之消失。 操作系统通过PCB表来管理和控制进程。 PCB主要包括进程描述信息、进程控制和管理信息、资源分配清单和处理机相关信息等。 (1)进程描述信息。 * 进程标识符:标志各个进程,每个进程都有一个唯一的标识号。 * 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务。 (2)进程控制和管理信息。 * 进程当前状态:描述进程的状态信息,作为处理机分配调度的依据。 * 进程优先级:描述进程抢占处理机的优先级,优先级高的进程可优先获得处理机。 (3)资源分配清单,用于说明有关内存地址空间或虚拟地址空间的情况,所打开文件的列表和所使用的输入/输出设备信息。 (4)CPU相关信息,主要指CPU中各寄存器的值,当进程被切换时,处理机状态信息都必须保存在相应的PCB中,以便在该进程重新执行时,能从断点继续执行。 PCB的组织方式:链接方式、索引方式 (1)链接方式:将同一状态的PCB链接成一个队列,不同状态对应不同的队列。 (2)索引方式:将同一状态的进程组织在一个索引表中,索引表的表项指向相应的PCB,不同状态对应不同的索引表。 2. 程序段 程度段就是能被进程调度程序调度到CPU执行的程序代码段。 注意:程序可被多个进程共享,即多个进程可以允许同一程序。 3. 数据段 一个进程的数据段,可以是进程对应的程序加工处理的原始数据,也可以是程序执行时产生的中间或最终结果。
2. 进程间的通信方式有哪些?
每个进程的用户地址都是独立的,一般而言是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核。
进程通信方式有6种:管道、系统IPC(消息队列、共享内存、信号量、信号)、套接字socket
1. 管道 匿名管道PIPE: 1. 它是半双工的,具有固定的读端和写端 2. 它只能用于具有亲缘关系的进程之前通信(父子进程,兄弟进程) 3. 它可以看成是一种特殊的文件,对于它的读写也可以使用普通的read、write函数。 但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。 命名管道FIFO: 1. FIFO可以在无关的进程之间通信 2. FIFO有路径名与之关联,它以一种特殊设备文件形式存在于文件系统中。 2. 消息队列(message queue) 1. 消息的链表,存放在内核中。一个消息队列由一个标识符ID进行标识; 克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。 2. 消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级。 3. 消息队列独立于发送与接收进程。进程终止时,消息队列及其内容并不会被删除。 4. 消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。 //A进程要给B进程发送消息,A进程把数据放在对应的消息队列后就正常返回,B进程需要的时候再去读取数据。 //具有写权限的进程可以按照一定的规则向消息队列中添加信息;对消息队列具有读权限的进程可以从消息队列中读取信息。 3. 共享内存(shared memory) 它使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。 这种方式需要依赖于某种同步操作,如互斥锁和信号量等。 1. 共享内存是最快的一种IPC,因为进程是直接对内存进行存取。 2. 因为多个进程可以同时操作,所以需要进行同步。 3. 信号量+共享内存通常结合在一起使用,信号量用来同步对共享内存的访问。 4. 信号量semophere 1. 信息量是一个计数器,用来控制多个进程对共享资源的访问。 信号量用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。 2. 信号量用于进程间同步,若要在进程间传递数据需要结合共享内存。 3. 信号量基于操作系统的PV操作,程序对信号量的操作都是原子操作。 4. 每次对信号量的PV操作不仅限于对信号量值+1或-1,而且可以加减任意正整数。 5. 支持信号量组。 5. 信号signal 比较复杂的通信方式,用于通知进程某个事件已经发生。 6. 套接字socket 可用于不同主机间的进程通信;
管道:
ps auxf | grep mysql |是一个管道,它的功能是将前一个命令的输出作为后一个命令的输入; |是匿名管道,用完了就销毁。 管道通信效率低,不适合进程间频繁地交换数据。 所谓管道,就是内核里面的一串缓存。 管道传输的数据是无格式的流且大小受限。 管道只能一端写入,另一端读出 如果需要双向通信,则应该创建两个管道。 我们编写shell脚本时,能使⽤⼀个管道搞定的事情,就不要多⽤⼀个管道,这样可以减少创建⼦进程的系统开销。 * 对于匿名管道,它的通信范围是存在父子关系的进程。因为管道没有实体,也就是没有管道文件,只能通过fork来复制父进程fd文件描述符,来达到相互通信。 * 对于命名管道,它可以在不相关的进程间也能相互通信。因为命名管道,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。 * 不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持lseek之类的文件定位操作。
3. 进程状态
1、进程状态?状态转换?
三状态模型
五状态模型
1. 创建状态(New):进程正在被创建时的状态 2. 就绪状态(Ready):进程被加入到就绪队列中等待CPU调度运行 3. 运行状态(Running):进程正在被运行 4. 阻塞状态(Blocked):该进程正在等待某事件发生(如等待I/O,等待设备)而暂时停止运行 5. 结束状态(Exit):进程运行完毕的状态
//* NULL->创建状态:一个新进程被创建时的第一个状态 * 创建状态->就绪状态:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态//,这个过程是很快的 * 就绪状态->运行状态:被操作系统的进程调度器选中后,就分配给CPU正式运行该进程 * 运行状态->结束状态:当进程已经运行完成或出错时,会被操作系统作结束状态处理 * 运行状态->就绪状态:当处于运行状态的进程时间片用完时,操作系统会把进程变为就绪态//,接着从就绪态中选中另外一个进程运行 * 运行状态->阻塞状态:当进程请求某个事件且必须等待时,例如请求I/O时间 * 阻塞状态->就绪状态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态
如果有大量处于阻塞状态的进程,进程会占用大量物理内存空间; 所以,在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次运行的时候换入内存 那么,就需要一个新的状态来描述进程没有占用实际的物理内存空间的情况,这个状态就是挂起状态。
2、交换技术
当多个进程竞争内存资源时,会造成内存资源紧张//,并且,如果此时没有就绪进程,处理机会空闲,I/0速度比处理机速度慢得多,可能出现全部进程阻塞等待I/O。 针对以上问题,提出了两种解决方法: 1)交换技术:换出一部分进程到外存,腾出内存空间。 2)虚拟存储技术:每个进程只能装入一部分程序和数据。 在交换技术上,将内存中暂时不能运行的进程,或者暂时不用的数据和程序,换出到外存,来腾出足够的内存空间,把已经具备运行条件的进程,或进程所需的数据和程序换入到内存。 从而出现了进程的挂起状态:进程被交换到外存,进程状态就成为了挂起状态。
3、活动阻塞,静止阻塞,活动就绪,静止就绪
1)活动阻塞:进程在内存,但是由于某种原因被阻塞了。 2)静止阻塞:进程在外存,同时被某种原因阻塞了。 3)活动就绪:进程在内存,处于就绪状态,只要给CPU和调度就可以直接运行。 4)静止就绪:进程在外存,处于就绪状态,只要调度到内存,给CPU和调度就可以运行。 从而出现了: 活动就绪 -> 静止就绪 (内存不够,调到外存) 活动阻塞 -> 静止阻塞 (内存不够,调到外存) 执行 -> 静止就绪 (时间片用完)
4、七状态模型
如果有大量处于阻塞状态的进程,进程会占用大量物理内存空间; 所以,在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次运行的时候换入内存 那么,就需要一个新的状态来描述进程没有占用实际的物理内存空间的情况,这个状态就是挂起状态。
阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现 就绪挂起状态:进程在外存(硬盘),但只要进入内存,立刻运行
5、进程的控制
1. 创建进程:在操作系统中,终端用户登录系统、作业调度、系统提供服务、用户程序的应用请求等都会引起进程的创建。 (1)为新进程分配一个唯一的进程标识号,并申请一个空白的PCB; //PCB是有限的,若申请失败则创建失败 (2)为进程分配资源, //此处如果资源不足,进程就会进入等待状态,以等待资源 (3)初始化PCB (4)将进程插入就绪队列,等待被调度运行 2. 终止进程 进程可以有3种终止方式:正常结束、异常结束、外界干预 正常结束:表示进程的任务已经完成并准备退出运行。 异常结束:表示进程在运行时,发生了某种异常事件,使程序无法继续运行,如存储区越界、保护错、非法指令、特权指令错、I/O故障等。 外界干预(信号kill掉):指进程应外界的请求而终止运行。 撤销原语: (1)查找需要终止的进程PCB (2)如果处于执行状态,则立即终止该进程的执行,然后将CPU资源分配给其他进程 (3)如果其还有子进程,则应将其所有子进程终止 (4)将该进程所拥有的全部资源都归还给父进程或操作系统 (5)将其从PCB所在队列中删除 3. 阻塞进程 当进程需要等待某一事件完成时,它可以调用阻塞语句把自己阻塞等待。//而一旦被阻塞等待,它只能由另外一个进程唤醒。 (1)找到将要被阻塞进程标识号对应的PCB (2)如果该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行。 (3)把该PCB插入相应事件的等待队列。 4. 唤醒进程 当阻塞进程所期待的事件出现时,由发现者进程用唤醒语句叫醒它。 (1)在该事件的阻塞队列中找到相应进程的PCB。 (2)将其从阻塞队列中移出,并置其状态为就绪状态。 (3)把该PCB插入到就绪队列中,等待调度程度调度。 进程的阻塞和唤醒是一对功能相反的语句,如果某个进程调用了阻塞语句,则必须有一个与之对应的唤醒语句。
6、进程退出的方式?
1. 正常退出(自愿的) 多数进程是由于完成了工作而终止。当编译器完成了所给定程序的编译之后,编译器会执行一个系统调用告诉操作系统它完成了工作。 //这个调用在Unix中是exit,在Windows中是ExitProcess。 2. 错误退出(自愿的) 为了编译某个文件(例如foo.c),但是该文件不存在,于是编译器就会发出声明并退出。在给出了错误参数时,面向屏幕的交互式进程通常并不会直接退出,因为这从用户的角度来说并不合理,用户需要知道发生了什么并想要进行重试,所以这个时候应用程序通常会弹出一个对话框告知用户发生了系统错误,是需要重试还是退出。 3. 严重错误退出(非自愿的) 由进程引起的错误,通常是由于程序中的错误所导致的。例如,执行了一条非法指令,引用不存在的内存,或者除数为0等。 //例如Unix中,进程可以通知操作系统,它希望自行处理某种类型的错误,在这个错误中,进程会收到信号(中断),而不是在这类错误出现时直接终止进程。 4. 被其他进程杀死(非自愿的, kill) 某个进程执行系统调用告诉操作系统杀死某个进程。 //在Unix中,这个系统调用是kill;Win32中对应的函数是TerminateProcess(注意不是系统调用)
4. 正常进程、孤儿进程、僵尸进程、守护进程
正常进程: 正常情况下,子进程是通过父进程创建的,子进程再创建新的进程。子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程到底什么时候结束。 当一个进程完成它的工作终止之后,它的父进程需要调用wait()或者waitpid()系统调用取得子进程的终止状态。 unix提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息,就可以得到:在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存等。 但是仍然为其保留一定的信息,直到父进程通过wait/waitpid来取时才释放。保存信息包括: 1. 进程号 the process ID 2. 退出状态 the termination status of the process 3. 运行时间 the amount of CPU time taken by the process 等
守护进程: 指在后台运行的,没有控制终端与之相连的进程。它独立于控制终端,周期性地执行某种任务。 Linux的大多数服务器就是用守护进程的方式实现的,如Web服务器进程http等。 创建守护进程要点: (1)让程序在后台执行。方法是 调用fork()产生一个子进程,然后使父进程提出。 (2)调用setsid()创建一个新对话。控制终端、登录会话和进程组通常是从父进程继承下来的,守护进程要摆脱它们,不受它们的影响,方法是调用setsid()使进程成为一个会话组长。setsid()调用成功后,进程成为新的会话组长和进程组长,并与原来的登录会话、进程组合控制终端脱离。 (3)禁止进程重新打开控制终端。经过以上步骤,进程已经成为一个无终端的会话组长,但是它可以重新申请打开一个终端。为了避免这种情况发生,可以通过使进程不再是会话组长来实现。再一次通过fork()创建新的子进程,使调用fork的进程退出。 (4)关闭不再需要的文件描述符。子进程从父进程继承打开的文件描述符。如不关闭,将会浪费系统资源,造成进程所在的文件系统无法卸下以及引起无法预料的错误。首先获得最高文件描述符值,然后用一个循环程序,关闭0到最高文件描述符值的所有文件描述符。 (5)将当前目录更改为根目录。 (6)子进程从父进程继承的文件创建屏蔽字可能会拒绝某些许可权。为防止这一点,使用unmask(0)将屏蔽字清零。 (7)处理SIGCHLD信号。对于服务器进程,在请求到来时往往生成子进程处理请求。如果子进程等待父进程捕获状态,则子进程将成为僵尸进程(zombie),从而占用系统资源。如果父进程等待子进程结束,将增加父进程的负担,影响服务器进程的并发性能。在Linux下可以简单地将SIGCHLD信号的操作设为SIG_IGN。这样,子进程结束时不会产生僵尸进程。
孤儿进程: * 一个父进程退出,而它的一个或多个子进程还在运行,那么这些子进程将成为孤儿进程,将被init进程(进程号为1)所收养,并由init进程对这些子进程完成状态收集工作;
僵尸进程: * 一个进程使用fork创建子进程,如果子进程先退出,而父进程并未(调用wait或waitpid来)获取子进程的状态信息,子进程的进程描述符仍然保存在系统中,那么这种子进程将成为僵尸进程。
1、僵尸进程有什么危害?如何解决?
僵尸进程危害: * 在子进程退出的时候,内核释放该子进程所有的资源,但仍保留进程号、退出状态、运行时间等信息,直到父进程通过wait或waitpid对其进行释放; * 但如果父进程不对保留信息进行释放,进程号会一直被占用,然而系统所能使用的进程号是有限的,如果产生大量的僵尸进程,系统将因没有可用的进程号而导致系统不能产生新的进程。
解决僵尸进程的方法: 1. 父进程通过wait和waitpid等函数等待子进程结束//,但这样会导致父进程挂起; 2. 如果父进程很忙,那么可用signal函数注册信号处理函数,在信号处理函数调用wait/waitpid等子进程退出。 3. 如果父进程不关心子进程何时结束,那么可以用signal(SIGCHLD, SIG_IGN)通知内核,这样当子进程结束后,内核会对其进行回收; 4. 通过两次调用fork。父进程首先调用fork创建一个子进程然后waitpid等待子进程退出,子进程再fork一个孙进程后退出。这样子进程退出后会被父进程等待回收,而对于孙进程其父进程已经退出,所以孙进程成为一个孤儿进程,孤儿进程由init进程接管,孙进程结束后,init会等待回收。
5. 如何使用fork()函数?
fork()函数创建的子进程会完全复制父进程的资源,代码也不例外; fork()函数在父进程中返回的是子进程的pid,在子进程中返回的是0,如果为-1则说明创建子进程失败;
#include <sys/types.h> #include <unistd.h> #include <stdio.h> int main(){ pit_t pid = fork(); if(pid > 0){//当前是父进程 printf("我是父进程,我的进程号是:%d,我的父进程是:%d,我的子进程是:%d", getpid(), getppid(), pid); } else if(pid == 0){ printf("我是子进程,我的进程号是:%d, 我的父进程是:%d", getpid(), getppid()); } else if(pid == -1){ perror("fork"); exit(0); } return 0; }
6. 请回答以下fork和vfork的区别?
fork基本知识: fork():创建一个与当前进程映像一样的进程。 #include<ysy/types.h> #inlcude<unistd.h> pid_t fork(void); 成功调用fork()会创建一个新的进程,它几乎与调用fork()的进程一模一样,这两个进程都会继续运行。 * 在子进程中,成功的 fork()调用会返回0。 * 在父进程中fork()返回子进程的pid。 * 如果出现错误,fork()返回一个负值。 最常见的fork()用法是创建一个新的进程,然后使用exec()载入二进制映像,替换当前进程的映像。这种情况下,派生(fork)了新的进程,而这个子进程会执行一个新的二进制可执行文件的映像。这种“派生加执行”的方式是很常见的。 在早期的 Unix 系统中,创建进程比较原始。当调用 fork 时,内核会把所有的内部数据结构复制一份,复制进程的页表项,然后把父进程的地址空间中的内容逐页的复制到子进程的地址空间中。但从内核角度来说,逐页的复制方式是十分耗时的。现代的 Unix 系统采取了更多的优化,例如 Linux,采用了写时复制的方法,而不是对父进程空间进程整体复制。
vfork的基础知识: 在实现写时复制之前,Unix 的设计者们就一直很关注在fork后立刻执行exec所造成的地址空间的浪费。BSD的开发者们在3.0的BSD系统中引入了 vfork()系统调用。 #include <sys/types.h> #include <unistd.h> pid_t vfork(void); 除了子进程必须要立刻执行一次对exec的系统调用,或者调用_exit()退出,对vfork()的成功调用所产生的结果和fork()是一样的。vfork()会挂起父进程直到子进程终止或者运行了一个新的可执行文件的映像。通过这样的方式,vfork()避免了地址空间的按页复制。在这个过程中,父进程和子进程共享相同的地址空间和页表项。实际上vfork()只完成了一件事:复制内部的内核数据结构。因此,子进程也就不能修改地址空间中的任何内存。 vfork()是一个历史遗留产物,Linux本不应该实现它。需要注意的是,即使增加了写时复制,vfork()也要比fork()快,因为它没有进行页表项的复制。然而,写时复制的出现减少了对于替换fork()争论。实际上,直到2.2.0内核,vfork()只是一个封装过的fork()。因为对vfork()的需求要小于fork(),所以vfork()的这种实现方式是可行的。
fork和 vfork的区别: 1. fork()的子进程拷贝父进程的数据段和代码段; vfork()的子进程与父进程共享数据段 2. fork()的父子进程的执行次序不确定; vfork()保证子进程先运行,在调用exec或exit之前与父进程数据是共享的,在它调用exec或exit之后父进程才可能被调度运行。 3. vfork()保证子进程先运行,在它调用exec或exit之后父进程才可能被调度运行。如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁。 4. 当需要改变共享数据段中变量的值,则拷贝父进程。
fork实例:
int main(void){ pid_t pid; signal(SIGCHLD, SIG_IGN); printf("before fork pid: %d\n", getpid()); int abc = 10; pid = fork(); if(pid == -1){ //错误 perror("tile"); return -1; } if(pid > 0) { //父进程空间 abc++; printf("parent pid : %d\n", getpid()); pfintf("abc : %d\n", abc); sleep(20); } else if(pid == 0) { //子进程空间 abc++; printf("child : %d, parent : %d\n", getpid(), getppid()); printf("abc : %d", abc); } printf("fork after... \n"); return 0; }
7. 写时复制
Linux 采用了写时复制的方法,以减少fork时对父进程空间进程整体复制带来的开销。 写时复制是一种采取了惰性优化方法来避免复制时的系统开销。它的前提很简单:如果有多个进程要读取它们自己的那部门资源的副本,那么复制是不必要的。每个进程只要保存一个指向这个资源的指针就可以了。只要没有进程要去修改自己的“副本”,就存在着这样的幻觉:每个进程好像独占那个资源。从而就避免了复制带来的负担。如果一个进程要修改自己的那份资源“副本”,那么就会复制那份资源,并把复制的那份提供给进程。不过其中的复制对进程来说是透明的。这个进程就可以修改复制后的资源了,同时其他的进程仍然共享那份没有修改过的资源。所以这就是名称的由来:在写入时进行复制。 写时复制的主要好处在于:如果进程从来就不需要修改资源,则不需要进行复制。 惰性算法的好处就在于它们尽量推迟代价高昂的操作,直到必要的时刻才会去执行。 在使用虚拟内存的情况下,写时复制(Copy-On-Write)是以页为基础进行的。所以,只要进程不修改它全部的地址空间,那么就不必复制整个地址空间。在fork()调用结束后,父进程和子进程都相信它们有一个自己的地址空间,但实际上它们共享父进程的原始页,接下来这些页又可以被其他的父进程或子进程共享。 写时复制在内核中的实现非常简单。与内核页相关的数据结构可以被标记为只读和写时复制。如果有进程试图修改一个页,就会产生一个缺页中断。内核处理缺页中断的方式就是对该页进行一次透明复制。这时会清除页面的COW属性,表示着它不再被共享。 现代的计算机系统结构中都在内存管理单元(MMU)提供了硬件级别的写时复制支持,所以实现是很容易的。 在调用fork()时,写时复制是有很大优势的。因为大量的fork之后都会跟着执行exec,那么复制整个父进程地址空间中的内容到子进程的地址空间完全是在浪费时间:如果子进程立执行一个新的二进制可执行文件的映像,它先前的地址空间就会被交换出去。写时复制可以对这种情况进行优化。
8. select
、poll
、epoll
之间有什么区别?
select本质上是通过设置和轮询`fd_set`来检查是否有就绪的文件描述符 select缺点: 1. 单个进程可监视的文件描述符数量较少,在32位机器上默认为1024个,在64位机器上默认为2048个; 2. 每次调用`select`都需要把`fd_set`从用户空间拷贝到内核空间,文件描述符较多时开销较大; 3. 每次调用`select`都需要线性扫描`fd_set`,文件描述符较多时开销较大。
poll与select相似,不同之处在于poll使用pollfd链表结构保存文件描述符,因此与select相比,没有文件描述符数量的限制。 epoll提供了三个函数: - `epoll_create`用于创建一个`epoll`句柄; - `epoll_ctl`用于注册要监听的事件类型,其特点是: - 每次注册新的事件到`epoll`句柄中时,会把所有的文件描述符拷贝进内核空间,保证了每个文件描述符在整个过程中只拷贝一次,不会出现重复拷贝; - 为每个文件描述符指定一个回调函数,当事件发生时,就会调用这个回调函数,把就绪的文件描述符加入到就绪链表中; - `epoll_wait`用于等待事件的发生,唤醒等待中的进程;
epoll对文件描述符的操作有两种模式: 1. 水平触发:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件, 下次调用epoll_wait时,将会再次响应应用程序并通知此事件; 2. 边缘触发:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件, 如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件;
需要注意的是,表面上看epoll
的性能最好,但是连接数量较少并且都十分活跃的情况下,select
和poll
的性能可能较好,因为epoll
的通知机制需要使用回调函数。
9. 进程切换
进程切换是指处理机从一个进程的运行转到另一个进程上运行,在这个过程中,进程的运行环境产生了实质性的变化。 进程切换过程: 1. 保存处理机上下文,包括程序计数器和其他寄存器。 2. 更新PCB信息 3. 把进程的PCB移入相应的队列 4. 选择另一个进程执行,并更新其PCB 5. 更新内存管理的数据结构 6. 恢复处理机上下文
10. “调度”和“切换”区别?
调度:是指确定资源分配给哪个进程的行为,是一种决策行为。 切换:是指实际分配的行为,是一种执行行为。 一般来说,先有资源的调度,然后才有进程的切换。
3. 线程详解
1. 哪些资源是线程共享的?哪些资源是线程私有的?
简单版: 共享:代码段、数据段、堆 私有:TCB、线程ID、寄存器、栈 复杂版: * 线程共享:进程代码段、进程的公有数据、进程打开的文件描述符、信号的处理器、进程的当前目录和进程用户ID与进程组ID; * 线程私有:线程ID、寄存器里的值、栈、线程的私有数据、线程的优先级、信号屏蔽码、错误返回码。
2. 线程创建的方式有哪几种?
1. 使用初始函数创建线程; 2. 使用类对象创建线程; 3. 使用lambda匿名函数创建线程。
3. 为什么需要使用线程池?怎么实现线程池?
问:为什么需要线程池? 1. 过于频繁地创建或销毁线程会带来大量系统开销,影响处理效率; 2. 线程并发数量过多,抢占系统资源从而导致阻塞; 3. 可以对线程进行一些简单的管理,如延时执行、定时循环执行。
问:怎么实现线程池? 1. 设置一个生产者消费者队列,作为临界资源 2. 初始化n个线程,并让其运行起来,加锁去队列取任务运行 3. 当任务队列为空的时候,所有线程阻塞 4. 当生产者队列来了一个任务后,先对队列加锁,把任务挂在到队列上,然后使用条件变量去通知阻塞中的一个线程
4. 如何使用单线程处理高并发?
在单线程模型中,采用多路复用I/O来提高单线程处理多个请求的能力,然后采用事件驱动模型,基于异步回调来处理事件。
5. 线程的上下文切换?
* 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样 * 当两个线程属于同一个进程,虚拟内存是共享的,只需要切换线程的私有数据、寄存器、栈等不共享的数据
6. 线程的实现
主要有三种线程的实现方式: 1. 用户级线程(User Thread):在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理 2. 内核级线程(KernelThread):在内核中实现的线程,是由内核管理的线程 3. 轻量级进程(LightWeight Process):在内核中来支持用户线程
多线程模型: 有些系统同时支持用户线程和内核线程,由此产生了不同的多线程模型,即实现用户级线程和内核级线程的连接方式。 1. 多对一:多个用户线程映射到一个内核线程,线程管理在用户空间完成。此模型中用户线程对操作系统不可见。 优点:线程管理是在用户空间进行的,因而效率比较高。 缺点:一个线程在使用内核服务时被阻塞,整个进程都会被阻塞;多个线程不能并发地运行在多核CPU上。 2. 一对一模型:将每个用户线程映射到一个内核线程 优点:当一个线程阻塞后,允许另一个线程继续执行,所以并发能力较强。 缺点:每创建一个用户线程都需要创建一个内核线程与之对应,开销较大,影响应用程序的性能。 3. 多对多模型:将n个用户线程映射到m个内核线程上,m<=n 特点:上面两个模型的折中,既克服了多对一模型并发度的缺点,又克服了一对一模型用户线程占用太多内核线程而开销大的缺点。
1、用户线程优缺点?
用户线程是基于用户态的线程管理库来实现的,TCB也是在库里面实现的; 对于操作系统来说看不到TCB,它只能看到整个进程的PCB 用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等。
优点: 1. 每个进程都需要有它私有的线程控制块列表,用来跟踪记录它各个线程状态信息(PC、栈指针、寄存器),TCB由用户级线程库函数来维护,可用于不支持线程技术的操作系统。 2. 用户线程的切换也是由线程库函数来完成的,无需用户态与内核态的切换,所以速度特别快 缺点: 1. 由于操作系统不参与线程的调度,如果一个线程发起了系统调用而阻塞,那进程所包含的用户线程都不能执行了 2. 当一个线程开始运行后,除非它主动地交出CPU的使用权,否则它所在的进程当中的其他线程无法运行,因为用户态的线程没法打断当前运行中的线程,它没有这个特权,只有操作系统才有,但是用户线程不是由操作系统管理的 3. 由于时间片分配给进程,故与其他进程比,在多线程执行时,每个线程得到的时间片较少,执行会比较慢
2、内核线程优缺点?
内核线程是由操作系统管理的,线程对应的TCB也是放在操作系统里,这样线程的创建、终止和管理都是由操作系统负责
优点: 1. 在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行 2. 分配给线程,多线程的进程获得更多的CPU运行时间 缺点: 1. 在支持内核线程的操作系统中,由内核来维护进程和线程的上下文信息,如PCB和TCB 2. 线程的创建、终止和切换都是通过系统调用的方式来进行,因为对于系统来说,系统来说比较大
7. 说一说协程
1、概念:协程,又称微线程,纤程,英文名Coroutine。 协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。 例如: def A() : print '1' print '2' print '3' def B() : print 'x' print 'y' print 'z' 由协程运行结果可能是 12x3yz。在执行A的过程中,可以随时中断,去执行B,B也可能在执行过程中中断再去执行A。但协程的特点在于是一个线程执行。
2)协程和线程区别: 1. 和多线程比,协程最大的优势就是协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。 2. 第二大优势就是不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。
3)其他 在协程上利用多核CPU呢——多进程+协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。 Python对协程的支持还非常有限,用在generator中的yield可以一定程度上实现协程。虽然支持不完全,但已经可以发挥相当大的威力了。
8. 常用线程模型
1、Future模型 该模型通常在使用的时候需要结合Callable接口配合使用。 Future是把结果放在将来获取,当前主线程并不急于获取处理结果。允许子线程先进行处理一段时间,处理结束之后就把结果保存下来,当主线程需要使用的时候再向子线程索取。 Callable是类似于Runnable的接口,其中call方法类似于run方法,所不同的是run方法不能抛出受检异常没有返回值,而call方法则可以抛出受检异常并可设置返回值。两者的方法体都是线程执行体。 2、fork&join 模型 该模型包含递归思想和回溯思想,递归用来拆分任务,回溯用合并结果。 可以用来处理一些可以进行拆分的大任务。其主要是把一个大任务逐级拆分为多个子任务,然后分别在子线程中执行,当每个子线程执行结束之后逐级回溯,返回结果进行汇总合并,最终得出想要的结果。 这里模拟一个摘苹果的场景:有 100 棵苹果树,每棵苹果树有 10 个苹果,现在要把他们摘下来。为了节约时间,规定每个线程最多只能摘 10 棵苹树以便于节约时间。各个线程摘完之后汇总计算总苹果树。 3、actor 模型 actor 模型属于一种基于消息传递机制并行任务处理思想,它以消息的形式来进行线程间数据传输,避免了全局变量的使用,进而避免了数据同步错误的隐患。actor 在接受到消息之后可以自己进行处理,也可以继续传递(分发)给其它 actor 进行处理。在使用 actor 模型的时候需要使用第三方 Akka 提供的框架。 4、生产者消费者模型 生产者消费者模型都比较熟悉,其核心是使用一个缓存来保存任务。开启一个/多个线程来生产任务,然后再开启一个/多个来从缓存中取出任务进行处理。这样的好处是任务的生成和处理分隔开,生产者不需要处理任务,只负责向生成任务然后保存到缓存。而消费者只需要从缓存中取出任务进行处理。使用的时候可以根据任务的生成情况和处理情况开启不同的线程来处理。比如,生成的任务速度较快,那么就可以灵活的多开启几个消费者线程进行处理,这样就可以避免任务的处理响应缓慢的问题。 5、master-worker 模型 master-worker 模型类似于任务分发策略,开启一个 master 线程接收任务,然后在 master中根据任务的具体情况进行分发给其它 worker 子线程,然后由子线程处理任务。如需返回结果,则 worker 处理结束之后把处理结果返回给 master。
9. 线程通信
1、线程同步的方法有哪些?
通信就是同步,同步就是通信,一个意思。 线程通信有四种:临界区、互斥量、信号量、事件
1. 临界区:通过多线程的串行化访问公共资源或代码段,速度较快;适合控制数据访问。 2. 互斥量Synchronized/Lock:采用互斥对象机制,只有拥有互斥对象的线程才能访问公共资源; 因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问; 3. 信号量Semphare:允许多个线程在同一时刻访问同一公共资源,但限制同一时刻访问该公共资源的最大线程数量; 4. 事件(信号)Wait/Notify:通过通知操作的方式来保持多线程同步,可以方便地实现多线程优先级的比较操作。
2、什么是临界区?进程进入临界区的调度原则是什么?
临界区是一段针对共享资源的保护代码,该保护代码在任意时刻只允许一个线程对共享资源访问。 线程进入临界区的调度原则是: 1. 如果有若干进程要求进入空闲的临界区,则每次只允许一个进程进入; 2. 任何时候,处于临界区内的进程不可多于一个; 3. 进入临界区的进程要在有限时间内退出,以便其他进程能及时进入临界区; 4. 如果进程不能进入临界区,则应让出CPU,避免进程出现忙等现象。
信号量:信号量是一种特殊的变量,可用于线程同步。它只取自然数值,并且只支持两种操作: * P(SV):如果信号量 SV 大于 0,将它减一;如果 SV 值为 0,则挂起该线程。 * V(SV):如果有其他进程因为等待 SV 而挂起,则唤醒,然后将 SV+1;否则直接将 SV+1。 其系统调用为: * sem_wait(sem_t *sem):以原子操作的方式将信号量减1,如果信号量值为0,则 sem_wait将被阻塞,直到这个信号量具有非0值。 * sem_post(sem_t *sem):以原子操作将信号量值+1。当信号量大于0时,其他正在调用sem_wait等待信号量的线程将被唤醒。
互斥量:互斥量又称互斥锁,主要用于线程互斥,不能保证按序访问,可以和条件锁一起实现同步。 * 当进入临界区时,需要获得互斥锁并且加锁; * 当离开临界区时,需要对互斥锁解锁,以唤醒其他等待该互斥锁的线程。 其主要的系统调用如下: * pthread_mutex_init:初始化互斥锁 * pthread_mutex_destroy:销毁互斥锁 * pthread_mutex_lock:以原子操作的方式给一个互斥锁加锁,如果目标互斥锁已经被上锁,pthread_mutex_lock调用将阻塞,直到该互斥锁的占有者将其解锁。 * pthread_mutex_unlock:以一个原子操作的方式给一个互斥锁解锁。
条件变量,又称条件锁,用于在线程之间同步共享数据的值。 条件变量提供一种线程间通信机制:当某个共享数据达到某个值时,唤醒等待这个共享数据的一个/多个线程。 即当某个共享变量等于某个值时,调用signal/broadcast。此时操作共享变量时需要加锁。 其主要的系统调用如下: * pthread_cond_init:初始化条件变量 * pthread_cond_destroy:销毁条件变量 * pthread_cond_signal:唤醒一个等待目标条件变量的线程。哪个线程被唤醒取决于调度策略和优先级。 * pthread_cond_wait:等待目标条件变量。需要一个加锁的互斥锁确保操作的原子性。该函数中在进入wait状态前首先进行解锁,然后接收到信号后会再加锁,保证该线程对共享资源正确访问。
3、说一说多线程的同步,锁的机制
同步的时候用一个互斥量,在访问共享资源前对互斥量进行加锁,在访问完成后释放互斥量上的锁。对互斥量进行加锁以后,任何其他试图再次对互斥量加锁的线程将会被阻塞直到当前线程释放该互斥锁。如果释放互斥锁时有多个线程阻塞,所有在该互斥锁上的阻塞线程都会变成可运行状态,第一个变为运行状态的线程可以对互斥量加锁,其他线程将会看到互斥锁依然被锁住,只能回去再次等待它重新变为可用。在这种方式下,每次只有一个线程可以向前执行。
4. 进程上下文
(1)进程的上下文可以分为哪几个部分?
1. 用户级上下文:正文、数据、用户堆栈以及共享存储区; 2. 寄存器上下文:通用寄存器、程序寄存器(IP)、处理器状态寄存器(EFLAGS)、栈指针(ESP); 3. 系统级上下文:进程控制块(task_struct)、内存管理信息(mm_struct、vm_area_struct、pgd、pte)、内核栈。
(2)什么是进程切换/上下文切换?
进程切换即上下文切换,是指处理器从一个进程切换到另一个进程,内核在处理器上对于进程进行以下操作:
- 挂起一个进程,将这个进程在处理器中的状态(即上下文)存储于内存中;
- 在内存中检索下一个进程的上下文,并将其在CPU的寄存器中恢复;
- 跳转到程序计数器所指向的位置(即跳转到进程被中断时的代码行),以恢复该进程。
(3)进程的上下文切换
各个进程之间是共享CPU资源的,在不同的时候进程之间需要切换,让不同的进程可以在CPU执行 一个进程切换到另一个进程运行,称为进程的上下文切换。 CPU的上下文切换就是先把前一个任务的CPU上下文(CPU寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。 CPU上下文切换分成: * 进程上下文切换 * 线程上下文切换 * 中断上下文切换
(4)进程的上下文切换是切换什么?
进程是由内核管理和调度的,所以进程的切换只能发生在内核态。 进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包含了内核堆栈、寄存器等内核空间的资源。
(5)进程的上下文切换有哪些场景?
* 当某个进程的时间片耗尽,进程就从运行状态变为就绪状态,系统从就绪队列选择另外一个进程运行 * 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行 * 当进程通过 睡眠函数sleep这样的方法将自己主动挂起时,自然也会重新调度 * 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行 * 发生硬件中断时,CPU上的进程会被中断挂起,转而执行内核中的中断服务程序
(6)线程需要保存哪些上下文,SP、PC、EAX 这些寄存器是干嘛用的
线程在切换的过程中需要保存当前线程Id、线程状态、堆栈、寄存器状态等信息。(就是保持不共享的那些信息) 其中寄存器主要包括 SP PC EAX 等寄存器,其主要功能如下: * SP : 堆栈指针,指向当前栈的栈顶地址 * PC : 程序计数器,存储下一条将要执行的指令 * EAX : 累加寄存器,用于加法乘法的缺省寄存器