线程公有私有
- 线程共享:进程代码段、进程的公有数据、进程打开的文件描述符、信号的处理器、进程的当前目录和进程用户ID与进程组ID;
- 线程私有:线程ID、寄存器里的值、栈、线程的私有数据、线程的优先级、信号屏蔽码、错误返回码。
进程和线程有什么区别?
- 进程是资源分配的最小单位,拥有独立的地址空间,每启动一个进程,系统都会建立数据表来维护其代码段、堆栈段和数据段;创建和切换进程的开销较大;进程间通信较复杂;多进程程序更加安全,进程间互不影响。
- 线程是程序执行的最小单位,没有独立的地址空间;创建和切换线程的开销较小;线程间通信较方便;多线程程序不易维护,线程间相互影响。
多进程和多线程有什么区别
- 多进程中数据是分离的,这样共享复杂,同步简单;而多线程中数据是共享的,这样共享简单,同步复杂;
- 进程创建、销毁和切换比较复杂,速度较慢;线程创建、销毁和切换比较简单,速度较快;
- 进程占用内存多,CPU利用率低;线程占用内存少,CPU利用率高;
- 多进程的编程和调试比较简单,多线程的编程和调试比较复杂;
- 进程间不会相互影响;而一个线程挂掉将导致整个进程挂掉;
- 多进程适用于多核、多机分布;多线程适用于多核分布。
多进程和多线程之间如何选择?
- 使用多进程的场景:弱相关的任务、需要拓展到多机分布的任务;
- 使用多线程的场景:需要频繁创建和销毁的任务(如Web服务器)、需要进行大量计算的任务、强相关的任务、需要拓展到多核分布的任务。
进程的组成部分有哪些?
进程通常由进程控制块和相应的地址空间组成;
Linux环境下,进程控制块主要包含:
- 进程标识符(即进程ID);
- 进程的当前状态;
- 相应的进程控制信息;
地址空间又可以分为:
- 文本段:存放相应的程序代码;
- 用户数据段:存放相应的程序处理数据;
- 系统数据段:存放相应的程序运行环境。
进程的基本状态有哪些?
- 匿名管道
pipe
:半双工的通信方式,数据只能单向流动,且只能在有亲缘关系的进程间使用; - 有名管道
named pipe
:与pipe
相似,但允许在无亲缘关系的进程间使用; - 消息队列
message queue
:消息链表,存放在内核中并由消息队列标识符进行标识,克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点; - 共享存储
shared memory
:映射一段能够被其他进程访问的内存,一般与信号量配合使用; - 信号量
semophore
:计数器,用来控制多个进程对共享资源的访问,常常作为锁机制,用于不同进程间或同一进程内不同线程间的同步; - 套接字
socket
:可用于不同机器间的进程通信; - 信号
signal
:比较复杂,用于通知进程某个事件已经发生。
进程的上下文可以分为哪几个部分?
- 用户级上下文:正文、数据、用户堆栈以及共享存储区;
- 寄存器上下文:通用寄存器、程序寄存器(IP)、处理器状态寄存器(EFLAGS)、栈指针(ESP);
- 系统级上下文:进程控制块(task_struct)、内存管理信息(mm_struct、vm_area_struct、pgd、pte)、内核栈。
什么是进程切换/上下文切换?
进程切换即上下文切换,是指处理器从一个进程切换到另一个进程,内核在处理器上对于进程进行以下操作:
- 挂起一个进程,将这个进程在处理器中的状态(即上下文)存储于内存中;
- 在内存中检索下一个进程的上下文,并将其在CPU的寄存器中恢复;
- 跳转到程序计数器所指向的位置(即跳转到进程被中断时的代码行),以恢复该进程
什么是孤儿进程?什么是僵尸进程?
-
孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么这些子进程将成为孤儿进程,将被
init
进程(进程号为1)所收养,并由init
进程对这些子进程完成状态收集工作; -
僵尸进程:一个进程使用
fork
创建子进程,如果子进程退出,而父进程并未调用wait
或waitpid
来获取子进程的状态信息,子进程的进程描述符仍然保存在系统中,那么这种子进程将成为僵尸进程。僵尸进程的危害:在子进程退出的时候,内核释放该子进程所有的资源,但仍保留进程号、退出状态、运行时间等信息,直到父进程通过
wait
或waitpid
对其进行释放;但如果父进程不对保留信息进行释放,进程号会一直被占用,然而系统所能使用的进程号是有限的,如果产生大量的僵尸进程,系统将因没有可用的进程号而导致系统不能产生新的进程。
解决僵尸进程的方法:
- 父进程通过
wait
和waitpid
等函数等待子进程结束,但这样会导致父进程挂起; - 如果父进程很忙,那么可用
signal
函数为SIGCHLD
安装handler
,这样当子进程结束后,父进程会收到信号,在handler
中调用wait
回收; - 如果父进程不关心子进程何时结束,那么可以用
signal(SIGCHLD, SIG_IGN)
通知内核,这样当子进程结束后,内核会对其进行回收; -
fork
两次,父进程fork
一个子进程后继续工作,子进程fork
一个孙进程后退出,那么孙进程将被init
接管,这样当子进程结束后,内核会对其进行回收。
线程同步方法
- 临界区:串行化访问公共资源或代码段,速度较快;
- 互斥量:采用互斥对象机制,只有拥有互斥对象的线程才能访问公共资源,而因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问;
- 信号量:允许多个线程在同一时刻访问同一公共资源,但限制同一时刻访问该公共资源的最大线程数量;
- 事件:使用通知操作的方式,可以方便地实现多线程优先级的比较操作。
什么是临界区
临界区是一段针对共享资源的保护代码,该保护代码在任意时刻只允许一个线程对共享资源访问。
线程进入临界区的调度原则是:
- 如果有若干进程要求进入空闲的临界区,则每次只允许一个进程进入;
- 任何时候,处于临界区内的进程不可多于一个;
- 进入临界区的进程要在有限时间内退出,以便其他进程能及时进入临界区;
- 如果进程不能进入临界区,则应让出CPU,避免进程出现忙等现象。
线程创建的方式有哪几种?
- 使用初始函数创建线程;
- 使用类对象创建线程;
- 使用lambda匿名函数创建线程。
为什么要使用线程池
- 过于频繁地创建或销毁线程会带来大量系统开销,影响处理效率;
- 线程并发数量过多,抢占系统资源从而导致阻塞;
- 可以对线程进行一些简单的管理,如延时执行、定时循环执行。
多线程锁的种类有哪些?
互斥锁(mutexlock):
最常使用于线程同步的锁;标记用来保证在任一时刻,只能有一个线程访问该对象,同一线程多次加锁操作会造成死锁;临界区和互斥量都可用来实现此锁,通常情况下锁操作失败会将该线程睡眠等待锁释放时被唤醒
自旋锁(spinlock):
同样用来标记只能有一个线程访问该对象,在同一线程多次加锁操作会造成死锁;使用硬件提供的swap指令或test_and_set指令实现;同互斥锁不同的是在锁操作需要等待的时候并不是睡眠等待唤醒,而是循环检测保持者已经释放了锁,这样做的好处是节省了线程从睡眠状态到唤醒之间内核会产生的消耗,在加锁时间短暂的环境下这点会提高很大效率
读写锁(rwlock):
高级别锁,区分读和写,符合条件时允许多个线程访问对象。处于读锁操作时可以允许其他线程和本线程的读锁, 但不允许写锁, 处于写锁时则任何锁操作都会睡眠等待;常见的操作系统会在写锁等待时屏蔽后续的读锁操作以防写锁被无限孤立而等待,在操作系统不支持情况下可以用引用计数加写优先等待来用互斥锁实现。 读写锁适用于大量读少量写的环境,但由于其特殊的逻辑使得其效率相对普通的互斥锁和自旋锁要慢一个数量级;值得注意的一点是按POSIX标准 在线程申请读锁并未释放前本线程申请写锁是成功的,但运行后的逻辑结果是无法预测
递归锁(recursivelock):
严格上讲递归锁只是互斥锁的一个特例,同样只能有一个线程访问该对象,但允许同一个线程在未释放其拥有的锁时反复对该锁进行加锁操作; windows下的临界区默认是支持递归锁的,而linux下的互斥量则需要设置参数PTHREAD_MUTEX_RECURSIVE_NP,默认则是不支持
什么是死锁?死锁产生的原因是什么?
死锁是指多个进程因竞争资源而造成的一种僵局(互相等待),若无外力作用,则这些进程都将无法向前推进。
死锁产生的原因:系统资源竞争、线程运行推进顺序不当。
死锁的四个必要条件:
- 互斥条件:在一段时间内某资源只能被一个进程使用,如果有其他进程请求该资源,则请求进程只能等待;
- 请求与保持条件:进程已经保持了至少一个资源,但又提出新的资源请求,而该资源已被其他进程占用,此时请求进程被阻塞,对自己已有资源保持不放;
- 不可剥夺条件:进程所获得的资源在未使用完毕时,不能被其他进程强行夺走,即只能由获得该资源的进程主动释放;
- 循环等待条件:若干进程间形成首尾相接、循环等待资源的关系。
如何预防死锁?如何避免死锁?
预防死锁的方法:核心思想是破坏死锁的四个必要条件之一,即
- 破坏请求与保持条件:采用预先静态分配的方法,即进程在运行前一次申请完它所需要的全部资源,在未满足全部资源时不运行;但系统资源被严重浪费,且易导致“饥饿”状态;
- 破坏不可剥夺条件:当一个进程请求新的资源但未满足时,该进程必须释放已经保持的所有资源,以后需要时再重新申请;但反复地申请和释放资源会增加系统开销,降低系统吞吐量;
- 破坏循环等待条件:采用顺序资源分配法,即给系统中的资源编号,规定每个进程必须按照编号顺序申请资源;但限制了新类型设备的增加。
避免死锁的方法:
- 进程启动拒绝:如果一个进程的请求会导致死锁,则不执行该进程;
- 资源分配拒绝:又名银行家算法,如果一个进程增加的资源请求会导致死锁,则不允许分配。
检测死锁的方法:
- 为每个进程和每个资源制定唯一编号;
- 设定一张资源分配表,记录各进程与占用资源之间的对应关系;
- 设定一张进程等待表,记录各进程与申请资源之间的对应关系;
- 判断是否出现环路,是则引发死锁。
解除死锁的方法:
- 资源剥夺法:挂起某些死锁进程,并释放其所保持的资源,分配给其他死锁进程,但应防止被挂起的进程长时间得不到资源,而处于资源匮乏的状态;
- 撤销进程法:强制撤销部分、甚至全部死锁进程,并释放其所保持的资源;撤销顺序可以按照进程优先级和撤销进程代价的高低来进行;
- 进程回退法:让某些死锁进行回退到足以回避死锁的地步,回退过程中自愿释放自愿,但要求系统保留进程历史信息,设置还原点。
Linux的I/O模型包含哪五种?各自有什么特点?
- 同步I/O
- 阻塞I/O:进程保持阻塞状态,直到数据拷贝完成;
- 非阻塞I/O:轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理;需要注意的是,拷贝数据的过程中,进程依然是阻塞装填;
- 多路复用I/O:进程调用
select
、poll
、epoll
函数,保持阻塞状态,但与阻塞I/O不同的是,这些函数可以同时处理多个I/O; - 信号驱动I/O:首先建立一个信号处理函数,进程继续运行并不阻塞,当数据准备好时,进程会收到一个
SIGIO
信号,可以在信号处理函数中处理数据;
- 异步I/O:在发起一个调用之后,调用者不能立即得到调用结果的返回,需要被调用者通过状态、通知和回调来通知调用者。
Select&Poll&Epoll之间有什么区别
-
select
本质上是通过设置和轮询fd_set
来检查是否有就绪的文件描述符,其缺点在于:- 单个进程可监视的文件描述符数量较少,在32位机器上默认为1024个,在64位机器上默认为2048个;
- 每次调用
select
都需要把fd_set
从用户空间拷贝到内核空间,文件描述符较多时开销较大; - 每次调用
select
都需要线性扫描fd_set
,文件描述符较多时开销较大。
-
poll
与select
相似,不同之处在于poll
使用pollfd
链表结构保存文件描述符,因此与select
相比,没有文件描述符数量的限制。 -
epoll
提供了三个函数:-
epoll_create
用于创建一个epoll
句柄; -
epoll_ctl
用于注册要监听的事件类型,其特点是:- 每次注册新的事件到
epoll
句柄中时,会把所有的文件描述符拷贝进内核空间,保证了每个文件描述符在整个过程中只拷贝一次,不会出现重复拷贝; - 为每个文件描述符指定一个回调函数,当事件发生时,就会调用这个回调函数,把就绪的文件描述符加入到就绪链表中;
- 每次注册新的事件到
-
epoll_wait
用于等待事件的发生,唤醒等待中的进程;
epoll
对文件描述符的操作有两种模式:- 水平触发:当
epoll_wait
检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件,下次调用epoll_wait
时,将会再次响应应用程序并通知此事件; - 边缘触发:当
epoll_wait
检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件,如果不处理,下次调用epoll_wait
时,不会再次响应应用程序并通知此事件;
-
需要注意的是,表面上看epoll
的性能最好,但是连接数量较少并且都十分活跃的情况下,select
和poll
的性能可能较好,因为epoll
的通知机制需要使用回调函数。