写点什么

嵌入式面试之《Linux 系统编程 100 问》

发布于: 2020 年 11 月 02 日
嵌入式面试之《Linux系统编程100问》

1. 什么是进程?

进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。

进程的特点:

动态性:进程的实质是程序在多道程序系统中的一次执行过程,进程是动态产生,动态消亡的。 并发性:任何进程都可以同其他进程一起并发执行。 独立性:进程是一个能独立运行的基本单位,同时也是系统分配资源和调度的独立单位; 异步性:由于进程间的相互制约,使进程具有执行的间断性,即进程按各自独立的、不可预知的速度向前推进。

进程的状态(进程三态):

进程执行时的间断性,决定了进程可能具有多种状态。事实上,运行中的进程可能具有以下三种基本状态:


1)就绪状态(Ready): 进程已获得除处理器(CPU)外的所需资源,等待分配处理器资源;只要分配了处理器进程就可执行。就绪进程可以按多个优先级来划分队列。例如,当一个进程由于时间片用完而进入就绪状态时,排入低优先级队列;当进程由 I/O 操作完成而进入就绪状态时,排入高优先级队列。

2)运行状态(Running): 进程占用处理器资源;处于此状态的进程的数目小于等于处理器的数目。在没有其他进程可以执行时(如所有进程都在阻塞状态),CPU 通常会自动执行系统的空闲进程(该空闲进程通常啥事不做,相当于空转)。

3)阻塞状态(Blocked): 由于进程等待某种条件(如 I/O 操作或进程同步),在条件满足之前无法继续执行。该事件发生前即使把处理器资源分配给该进程,也无法运行。

2. 什么是线程?

线程(thread)是操作系统能够进行调度的最小单位。它被包含在进程之中,一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

3. 进程和线程的联系与区别?

主要从以下几个方面进行归纳:

0)资源 进程之间的资源是独立的,当然包括内存地址空间;同一进程内的线程共享本进程的资源(除去线程占用的极少独立资源,比如线程的栈,寄存器,私有数据区)。

1)开销 线程上下文切换比进程上下文切换要快得多,也就是说 CPU 调度和切换开销小。切换开销小的原因在于线程占用的资源少,共享进程资源。

2)并发 在引入线程的操作系统中,不仅进程之间可以并发执行,而且在一个进程中的多个线程之间亦可并发执行,因而可以充分利用现代 CPU 多核的特点,使程序具有更好的并发性(并发的粒度更加细化),从而更高效地利用系统资源和提高系统吞吐量。

3)通信 线程之间通信更方便,同一个进程下,线程共享全局变量,静态变量等数据,进程之间的通信需要以内核提供的方式(IPC)进行。

4)可靠性(或者说健壮性) 多线程程序处理好同步与互斥是个难点,也就是说多线程程序增加了编程与调试的难度。同时,多进程的可靠性比较好,因为进程间不会相互影响(除非业务上有联系),线程崩溃时往往会影响整个进程组内的其他线程,包括进程本身,比如段错误时全部崩溃。

4. 多线程多进程的好处?

(1)有利于充分发挥多处理器的功能。通过创建多线程进程,每个线程在一个处理器上运行,从而实现应用程序的并发性,使每个处理器都得到充分运行。多进程也可以提高并发性。 (2)将大型应用程序划分成若干进程,有利于提高程序健壮性,同时有利于派发任务,并行开发,划清责任、缩小问题定位范围


5. 什么是父子,兄弟,僵尸,孤儿进程?

我们知道在 unix/linux 中,正常情况下,父进程通过fork/vfork系统调用创建子进程,子进程再创建新的进程又变成了其他进程的父进程。具有相同父进程的进程互称兄弟进程。子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程到底什么时候结束。 当一个进程完成它的工作终止之后,它的父进程需要调用 wait()或者 waitpid()系统调用取得子进程的终止状态。

孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被 init 进程(进程 id 为 1,又叫 1 号进程)所收养,并由 init 进程对它们完成状态收集工作

僵尸进程:一个进程使用 fork 创建子进程,如果子进程退出,而父进程并没有调用 wait 或 waitpid 获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。

6. 僵尸进程的危害?

在子进程退出时,如果父进程不调用 wait 或 waitpid 函数的话,那么系统保留的子进程的信息就不会被释放,其子进程号会一直被占用,但是系统所能使用的进程号是有限的,如果产生大量的僵尸进程,最终可能导致系统没有可用的进程号,从而不能产生新的进程。

例如有个进程,它定期的产生一个子进程,这个子进程需要做的事情很少,做完它该做的事情之后就退出了,因此这个子进程的生命周期很短,但是,父进程只管生成新的子进程,至于子进程退出之后的事情,则一概不闻不问,这样,系统运行上一段时间之后,系统中就会存在很多的僵死进程,倘若用 ps 命令查看的话,就会看到很多状态为 Z(Zombie)的进程。

严格地来说,僵尸进程并不是问题的根源,罪魁祸首是产生出大量僵死进程的那个父进程。因此,当我们寻求如何消灭系统中大量的僵死进程时,答案就是把产生大量僵死进程的那个元凶枪毙掉(也就是通过 kill 发送 SIGTERM 或者 SIGKILL 信号啦)。枪毙了元凶进程之后,它产生的僵死进程就变成了孤儿进程,这些孤儿进程会被 init 进程接管,init 进程会 wait()这些孤儿进程,释放它们占用的系统进程表中的资源,这样,这些已经僵死的孤儿进程就能瞑目而去了。

简而言之,危害就是占用系统资源(主要是进程号)。

孤儿进程由于被 init 进程所领养,所以没什么危害。


7. 子进程退出为何不释放全部资源,而要等待父进程回收?

这个问题换种问法就是,僵尸进程存在的原由

子进程在退出时,如果内核将其资源全部释放,那么就不会有僵尸进程的产生,内核为何不这么做呢?

unix 提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息, 就可以得到。这种机制就是: 在每个进程退出的时候,内核释放该进程绝大部分的资源,包括打开的文件,占用的内存等。但是仍然为其保留一定的信息(包括进程号 the process ID,退出状态 the termination status of the process,运行时间 the amount of CPU time taken by the process 等)。直到父进程通过 wait / waitpid 来取时才释放。

所以说,僵尸进程存在的目的是让父进程有机会获取子进程结束时的状态信息,为了实现这一机制,程序员也多了个负担,就是要手动调用 wait/waitpid 函数回收子进程状态信息。

8. 僵尸进程的解决办法?

解决办法当然是让父进程调用 wait / waitpid 函数来回收子进程状态信息,但是这两个函数会阻塞,如果父进程有自己的任务要做,那么就不能一直阻塞在 wait。子进程退出时会向父进程发送SIGCHILD信号,我们可以通过给父进程安装SIGCHILD信号处理函数的形式,来回收子进程状态信息


#include <stdio.h>#include <unistd.h>#include <errno.h>#include <stdlib.h>#include <signal.h>
static void sig_child(int signo);
int main(){ pid_t pid; //捕捉子进程退出信号 signal(SIGCHLD,sig_child); pid = fork(); if (pid < 0) { perror("fork error:"); exit(1); } else if (pid == 0) { printf("I am child process,pid id %d.I am exiting.\n",getpid()); exit(0); } printf("I am father process.I will sleep two seconds\n"); //等待子进程先退出 sleep(2); //输出进程信息 system("ps -o pid,ppid,state,tty,command"); printf("father process is exiting.\n"); return 0;}
static void sig_child(int signo){ pid_t pid; int stat; //处理僵尸进程 while ((pid = waitpid(-1, &stat, WNOHANG)) > 0)//-1表示回收任何进程,如果传入进程id,将回收指定进程 printf("child %d terminated.\n", pid);}
复制代码


9.什么是守护进程?

守护进程(daemon),是一种运行在后台的特殊进程,它独立于控制终端 ,并周期性地执行某项任务或等待处理某些发生的事件。

守护进程是个特殊的孤儿进程,这种进程脱离终端,为什么要脱离终端呢?之所以脱离于终端是为了避免进程被任何终端所产生的信号所打断,其在执行过程中的信息也不在任何终端上显示。

由于在 Linux 中,每一个系统与用户进行交流的界面称为终端,每一个从此终端开始运行的进程都会依附于这个终端,这个终端就称为这些进程的控制终端,当控制终端被关闭时,相应的进程都会自动关闭。但是守护进程却能突破这种限制,它脱离于终端并且在后台运行,并且它脱离终端的目的是为了避免进程在运行的过程中的信息在任何终端中显示并且进程也不会被任何终端所产生的终端信号所打断。它从被执行的时候开始运转,直到整个系统关闭才退出(当然可以人为的杀死相应的守护进程)。


10.守护进程的创建?

守护进程可以由一个普通的进程按照守护进程的特性改造而来。基本的流程如下:


1)首先应该屏蔽一些有关控制终端操作的信息,防止在守护进程还没有正常运行起来前受到控制终端的干扰退出或挂起。代码如下:

#ifdef SIGTTOU    signal(SIGTTOU, SIG_IGN);//忽略后台进程写控制终端信号#endif#ifdef SIGTTIN    signal(SIGTTIN, SIG_IGN);//忽略后台进程读控制终端信号#endif#ifdef SIGTSTP    signal(SIGTSTP, SIG_IGN);//忽略终端挂起#endif
复制代码


2) 前台转后台:首先在普通进程中调用 fork 函数之后,使父进程终止,让子进程继续执行,此时子进程称为孤儿进程,由于子进程是父进程的完全 copy,而子进程又在后台运行,完成“脱壳”。

if (0 > (pid = fork())) 	exit(-1);//让父进程退出
复制代码

3)脱离控制终端,登录会话和进程组。如果想要普通进程脱离控制终端,登录会话和进程组,不受他们影响,可以使用 setsid()设置新会话的首进程,使其与原来的登录会话和进程组自动分离。代码如下:

if (-1 == setsid()) 	exit(0);
复制代码

setsid()函数说明如下:

#include <sys/types.h>#include <unistd.h>
pid_t setsid(void);返回值:成功返回进程组ID,出错返回-1;
函数说明:如果,调用setsid的进程不是一个进程组的组长,此函数创建一个新的会话期。结果为
(1)此进程变成新会话的会话首进程,此时,此进程是该新会话组中的唯一进程。
(2)此进程变成一个新进程组的组长进程。新进程组ID是该调用进程的进程ID
(3)此进程没有控制终端,如果在调用setsid前,该进程有控制终端,那么与该终端的联系被解除。
复制代码

4)经过前面的是 3 个步骤,该子进程已经脱离了控制终端,原来的登录会话和进程,似乎已经完成从普通进程到守护进程的转换,但是,对于某些系统,如 SVR4,当会话首进程打开一个尚未与任何会话相关联的终端设备,此设备会自动作为控制终端分配给该会话。所以,我们需要采用不再让该子进程称为会话首进程的方式来禁止进程重新打开关联的控制终端。具体方法为再次调用 fork 函数,该 fork 函数执行后,子进程结束,那么孙子进程就不再是会话首进程,避免会话再次被关联到控制终端。当需要注意的是,当会话首进程退出的时候可能会向其所在的会话的所有进程发送 SIGHUP 信号,而 SIGHUP 信号的默认处理函数是结束进程,为了防止孙子进程意外结束,所以要忽略 SIGHUP 信号。

signal(SIGHUP,SIG_IGN);if (0 != fork()) 	exit(0);
复制代码

5)最后一步就是改变工作目录到根目录。因为进程活动时,其工作目录所在的文件系统不能被卸下,一般需要将工作目录改变到根目录。

if (0 != chdir("/")) 	exit(0);
复制代码


6)重设文件创建掩码 (一些进程不用) 进程从创建它的父进程那里继承了文件创建掩码。它可能修改守护进程所创建的文件的存取位。为防止这一点,将文件创建掩码清除:umask(0);

测试代码如下:

#include <unistd.h>   #include <signal.h>   #include <fcntl.h>  #include <sys/syslog.h>  #include <sys/param.h>   #include <sys/types.h>   #include <sys/stat.h>   #include <stdio.h>  #include <stdlib.h>  #include <time.h>  
int init_daemon(void) { int pid; int i;
// 1)屏蔽一些控制终端操作的信号 #ifdef SIGTTOU signal(SIGTTOU, SIG_IGN);#endif#ifdef SIGTTIN signal(SIGTTIN, SIG_IGN);#endif#ifdef SIGTSTP signal(SIGTSTP, SIG_IGN);#endif
// 2)在后台运行 if( (pid = fork()) < 0 ) exit(0); if(pid > 0){ //父进程退出 exit(0); }
// 3)脱离控制终端、登录会话和进程组 if (-1 == setsid()) exit(0);
// 4)禁止进程重新打开控制终端 signal(SIGHUP, SIG_IGN); if( pid = fork() ){ exit(0); // 结束第一子进程,第二子进程继续(第二子进程不再是会话组长) }else if(pid < 0){ // 出错 perror("fork"); exit(EXIT_FAILURE); }
// 5)改变当前工作目录 if (0 != chdir("/")) exit(0);
// 6)重设文件创建掩模 umask(0);
return 0; }
int main(int argc, char *argv[]) { init_daemon();
while(1);
return 0; }
复制代码

11.管道?

特点:

管道是半双工的,一个管道有两端,一个用来写入数据,另一个用来读取数据,数据只能向一个方向流动。

创建接口:

int pipe(int pipe_fd[2]);
复制代码

创建流程:

  1. 父进程调用 pipe(int pipe_fd[2])函数,创建管道并得到两个文件描述符,分别指向管道的头部和尾部,也就是读端和写端。

  2. 父进程调用 fork()函数创建子进程,因为 fork()函数,子进程拷贝父进程的数据段和代码段,所以子进程也能得到第一步创建的两个文件描述符,且指向同一个管道。

  3. 父进程关闭管道的读端,子进程关闭管道的写端(反过来也行,取决于谁向谁写数据)。


无名管道的特点:

  • 半双工(严格来说应该是单工,因为管道建立之后数据流向就不可更改了)

  • 只能用于具有亲缘关系的进程之间

  • 管道的缓冲区是有限的(ubuntu 实测 64K)

  • 管道所传送的是无格式字节流,这就要求管道的读出方和写入方必须事先约定好数据的格式,比如多少字节算作一个消息(或命令,或记录)等等。

  • 管道的数据被读走后就没了

  • 要注意,对写端进行写数据时,不需要关闭读端的缓冲文件(即不需要读端的文件描述符计数为 0),但是在读端进行读数据时必须先关闭写端的缓冲文件(即写端的文件描述符计数为 0)然后才能读取数据。

  • 向读端关闭的管道内写数据时,write() 所在进程会(收到 SIGPIPE 信号)退出,收到 SIGPIPE 默认动作为中断当前进程。

  • 管道无数据,调用 read 会阻塞;管道满,调用 write 也会阻塞。除非设置为非阻塞模式。

管道的实质是一个内核缓冲区:

  • 进程以先进先出的方式从缓冲区存取数据,管道一端的进程顺序的将数据写入缓冲区,另一端的进程则顺序的读出数据

  • 该缓冲区可以看做是一个循环队列,读和写的位置都是自动增长的,不能随意改变,一个数据只能被读一次,读出来以后在缓冲区就不复存在了

  • 当缓冲区读空或者写满时,有一定的规则控制相应的读进程或者写进程进入等待队列,当空的缓冲区有新数据写入或者满的缓冲区有数据读出来时,就唤醒等待队列中的进程继续读写。

FIFO 命名管道:

  • 匿名管道,由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道(FIFO)

  • FIFO 是一种文件类型,其会创建管道文件让不同进程之间进行通信。 详见:https://blog.csdn.net/tennysonsky/article/details/46326957

12.消息队列?

消息队列是消息的链表,存储在内核中,由消息队列标识符标识 。

特点总结:

  • 消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识

  • 消息队列允许一个或多个进程向它写入与读取消息

  • 管道和消息队列的通信数据都是先进先出的原则

  • 消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。比 FIFO 更有优势。

  • 息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺点。

  • 与无名管道、命名管道一样,从消息队列中读出消息,消息队列中对应的数据都会被删除。

  • 前主要有两种类型的消息队列:POSIX 消息队列以及 System V 消息队列,系统 V 消息队列目前被大量使用。系统 V 消息队列是随内核持续的,只有在内核重起或者人工删除时,该消息队列才会被删除。

13.信号(signal)?

信号(Signals )是 Unix 系统中使用的最古老的进程间通信的方法之一。操作系统通过信号来通知进程系统中发生了某种预先规定好的事件(一组事件中的一个),它也是用户进程之间通信和同步的一种原始机制。一个键盘中断或者一个错误条件(比如进程试图访问它的虚拟内存中不存在的位置等)都有可能产生一个信号。Shell 也使用信号向它的子进程发送作业控制信号。

常见的信号有:

  • SIGHUP:用户从终端注销,所有已启动进程都将收到该进程。系统缺省状态下对该信号的处理是终止进程

  • SIGINT:程序终止信号。程序运行过程中,按 Ctrl+C 键将产生该信号

  • SIGQUIT:程序退出信号。程序运行过程中,按 Ctrl+\键将产生该信号

  • SIGKILL:用户终止进程执行信号。shell 下执行 kill -9 发送该信号(该信号不能被捕获)

  • SIGTERM:结束进程信号。shell 下执行 kill 进程 pid 发送该信号(该信号可以被捕获)

  • SIGALRM:定时器信号

  • SIGCLD:子进程退出信号。如果其父进程没有忽略该信号也没有处理该信号,则子进程退出后将形成僵尸进程

信号是软件层次上对中断机制的一种模拟,是一种异步通信方式

14.信号量?

信号量本质上是一个非负的整数计数器,它被用来控制对公共资源的访问。当公共资源增加时,调用函数 sem_post()增加信号量。只有当信号量值大于 0 时,才能使用公共资源,使用后,用函数 sem_wait()减少信号量。

可以用来处理生产者与消费者之间的关系

PV 操作

  • **P(SV):**如果 SV 的值大于 0,就将它减 1;如果 SV 的值为 0,则挂起进程的执行

  • **V(SV):**如果有其他进程因为等待 SV 而挂起,则唤醒之;如果没有,则将 SV 加 1

例如:

  • 初始化时二进制信号量 SV 的值为 1

  • 此时进程 A 执行了 P(SV)操作将 SV 减 1,则进程 B 若再执行 P(SV)则会被挂起

  • 当 A 执行完离开关键代码段之后,并执行 V(SV)操作将 SV 加 1,则会唤醒进程 B 开始执行

15.共享内存(share memory)?

共享内存允许两个或多个进程共享一个给定的存储区。

  • 共享存储不属于某一特定进程,申请时是由系统提供的,大家都可以使用。

  • 因为数据不需要在进程之间复制,所以这是最高效的进程间通信方式。


解释:为了在多个进程间交换信息,内核专门留出了一块内存区,可以由需要访问的进程将其映射到自己的私有地址空间。进程就可以直接读写这一块内存而不需要进行数据的拷贝,从而大大提高效率


重点:父进程 fork 子进程或者 exec 执行一个新的程序。在子进程和新程序里面不会继承父进程之前使用的共享内存。

共享内存有什么要注意的:

因为共享内存大家都可以使用,所以当有多个进程对共享内存进行操作时,需要控制好读写操作(也就是同步问题),所以共享内存一般是配合信号量使用。

16.套接字(socket)?

套接字是一种通信机制,凭借这种机制,客户/服务器(即要进行通信的进程)系统的开发工作既可以在本地单机上进行,也可以跨网络进行

套接字的特性由 3 个属性确定:域,端口号,协议类型。

特点:

跨主机。

17.死锁的概念?

多线程改善了系统资源的利用率并且提高了系统的处理能力。但是,并发执行同时也带来了新的问题——死锁。所谓的死锁就是多个线程(或者进程)因竞争资源而造成的一种互相等待,如果没有外力作用,这些线程都将无法继续执行。

死锁的规范定义: 如果一个进程集合中的每个进程都在等待只能由该进程集合中的其他进程才能引发的事件,那么,该进程集合是死锁的。

死锁的两种典型案例 :

情况 1: 如果同一个线程先后两次加锁同一个锁,在第二次上锁时,由于锁已经被加锁,该线程会挂起等待别的线程释放锁;然而锁正是被自己占着的,该线程又被挂起,没有机会释放锁,因此,就永远处于挂起等待状态了。

情况 2 :另一种典型的死锁情形是这样:线程 A 获得了锁 1,线程 B 获得了锁 2,这时线程 A 试图获得锁 2,结果是需要挂起等待线程 B 释放锁 2;而这时线程 B 也试图获得锁 1,结果是需要挂起等待线程 A 释放锁 1,于是线程 A 和 B 都永远处于挂起状态了。

18.产生死锁的原因?

(1).竞争资源。当系统中供多个线程共享的资源如打印机,公用队列等,其数目不足以满足诸线程的需要的时候,会引起诸线程对资源的竞争而产生死锁。

(2).线程间推进顺序非法。线程在运行过程中,请求和释放资源的顺序不当,也同样会导致产生线程死锁。


下面详细分析产生死锁的原因:

(1)竞争资源引起死锁

1)可剥夺性和非剥夺性资源

可把系统的资源分成两类,一类是可剥夺性资源,是指某线程在获得这类资源后,该线程可以再被其它线程或系统剥夺。如优先级高的线程可以剥夺优先级低的线程的资源。又如线程间的切换。另一类是非剥夺性资源,当系统把这类资源分配给某个线程后,再不能强行收回,只能在其用完后自行释放,如磁带机、打印机等。

2).竞争非剥夺性资源

在系统所配置的非剥夺性资源,由于它们的数量不能满足诸线程的需要,会使线程在运行过程中,因争夺这些资源陷入僵局。

3).竞争临时性资源

打印机资源属于可顺序重复使用型资源,称为永久性资源。所谓的临时性资源,是指被线程使用一短暂时间后便无用的资源,故也称之为消耗性资源,它也可能引起死锁。

(2).线程推进顺序不当引起死锁

如开始所提线程 A 获得了锁 1,线程 B 获得了锁 2,这时线程 A 调试图获得锁 2,结果是需要挂起等待线程 B 释放锁 2,这时线程 B 也试图获得锁 1,结果是需要挂起等待线程 A 释放锁 1,于是线程 A 和 B 都将永远处于挂起状态了。

19.产生死锁的四个必要条件?

虽然线程在运行过程中可能发生死锁,但死锁的发生也必须具备一定的条件。综上所述不难看出,死锁的发生必须具备下列四个必要条件。

(1).互斥条件:指线程对所分配的资源进行排它性使用,即在一段时间内某资源只由一个线程占用。如果此时还有其它线程请求该资源,则请求者只能等待,直至占有该资源的线程用毕释放。

(2).请求和保持条件:指线程已经保持了至少一个资源,但又提出了新的资源请求,而该资源又被其它线程占有,或者已经拥有了该资源却又再次请求,此时请求线程阻塞,但又对自己已获得的资源保持不放。

(3).不剥夺条件:指线程在已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。(如果资源可抢占,那自然就不会有死锁问题)

(4)环路等待条件:指在发生死锁时,必然存在一个线程资源的依赖环形链,即线程集合{P0,P1,P2,P3,…Pn}中的 P0 正在等待 P1 占用的资源;P1 正在等待 P2 占用的资源,…,Pn 正在等待已被 P0 占用的资源。


这四个必要条件必须同时满足,才会发生死锁,只要一个不满足,就无法构成死锁,请谨记这四个条件。


20.预防死锁的方法?

预防死锁的方法是使四个必要条件中的第 2、3、4 个条件之一不能成立,来避免发生死锁。至于必要条件 1,因为它是由设备的固有特性所决定的,不仅不能改变,还应加以保证。

1.摒弃“请求和保持条件”

在采用这种方法时,系统规定所有线程在开始运行之前,都必须一次性的申请其在整个运行过程中所需的全部资源。

优点:简单,易于实现且安全

缺点:由于进程运行期间对所需资源的全部占用,使得某些使用时间很短的资源被长时间占用,这样会严重影响系统资源的充分利用,导致资源利用率降低,同吋也影响到未获得全部资源的进程推迟运行。

2.摒弃“不剥夺”条件

在采用这种方法时系统规定,线程是逐个提出对资源的要求的。当一个已经保持了某些资源的线程,在提出新的资源请求而不能立即得到满足时,必须释放它已经保持了的所有资源,待以后需要时再重新申请。

这种方法实现起来比较复杂且要付出很大的代价(为了保护进程自动放弃资源的现场以及后来的再次恢复)。并且有可能使进程的执行被无限的推迟,而且也增加了系统开销,降低了系统吞吐量。

3.摒弃”环路等待“条件

这种分配方法的基本思想是:把系统的全部资源分成多个层次,一个进程得到某一层的一个资源后,它只能再请较高一层的资源;当一个进程要释放某层的一个资源时,必须先释放所占有的较高层的资源;当一个进程获得了某一层的一个资源后,它想再申请该层中的另一个资源,就必须先释放在该层中巳占有的资源。或者说,进程释放资源的顺序是按照中请资源的相反顺序进行的。这样可以预防循环等待现象的发生,因此不会发生死锁。使用该方法要特別注意的问题是对资源所处层次的安排。在通常情况下,把各进程经常用到的、比较普遍的资源安排在较低的层次上,把重要且相对匮乏的资源安排在较高的层次上,以便实现对各资源的最大限度的利用。

优缺点:该方法相对于前面介绍的方法,在资源利用率和系统吞吐量上都有明显的改善。但也存在一些缺陷:

(1)低层次的资源必须在进程请求分配髙层次的资源之前提前申请,这对于暂时不需使用的低层次资源来说,会因空闲等待而产生浪费。

(2)各类设备的资源层次一经设定,便不能经常随意改动,这就限制了新类型设备的增加。

(3)各资源的层次是按照大多数进程使用资源的顺序设置的。对于资源使用与此层次相匹配的进程,资源能得到有效的利用,否则,资源的浪费现象将仍然存在。


实际常用的几种避免方法:

  • 1、资源按序分配 前面已经描述。

  • 2、限时加锁(资源限时申请) 若一个线程没有在给定的时限内成功获得所有需要的锁,则会进行回退并释放所有已经获得的锁,然后等待一段随机的时间再重试。这段随机的等待时间让其它线程有机会尝试获取相同的这些锁,并且让该应用在没有获得锁的时候可以继续运行。

面试官可能更偏向于问如何预防死锁,而非死锁的解决办法。死锁的解决办法往往是操作系统的任务,作为程序员,我们需要保证我们的程序不会发生死锁。

在问你如何避免死锁时,你可以回答上述两种方法,他必定非常满意。

21.读写锁机制探究概述?

读写锁与互斥锁的功能类似,对临界区的共享资源进行保护!互斥锁一次只让一个线程进入临界区,读写锁比它有更高的并行性。读写锁有以下特点:

  1. 如果一个线程用读锁锁定了临界区,那么其他线程也可以用读锁来进入临界区,这样就可以多个线程并行操作。但这个时候,如果再进行写锁加锁就会发生阻塞,写锁请求阻塞后,后面如果继续有读锁来请求,这些后来的读锁都会被阻塞!当读操作完成后,写锁解除阻塞。这样的目的是出于避免了读锁长期占用资源,防止写锁饥饿

  2. 如果一个线程用写锁锁住了临界区,那么其他线程不管是读锁还是写锁都会发生阻塞!

linux 下读写锁默认遵循写锁优先的原则。读写锁的优势往往展现在读操作很频繁,而写操作较少的情况下。在初始化读写锁时可以使用 PTHREAD_RWLOCK_INITIALIZER_READ_PREF 来设置读优先。

不论是写优先,都必须遵循读者共享,写者唯一的原则。

处理读者-写者问题的两种常见策略是强读者同步(strong reader synchronization)和强写者同步(strong writer synchronization)。 在强读者同步中,总是给读者更高的优先权,只要写者当前没有进行写操作,读者就可以获得访问权限;而在强写者同步中,则往往将优先权交付给写者,而读者只能等到所有正在等待的或者是正在执行的写者结束以后才能执行。关于读者-写者模型中,由于读者往往会要求查看最新的信息记录,所以航班订票系统往往会使用强写者同步策略,而图书馆查阅系统则采用强读者同步策略。

22.用互斥锁实现读写锁?

这个问题属于进阶问题。


//my_pthread_rwlock.h #pragma once #include<pthread.h>#include<stdio.h> typedef struct{ pthread_mutex_t rw_mutex; pthread_cond_t rw_condreaders; pthread_cond_t rw_condwriters; int rw_magic; int rw_nwaitreaders; int rw_nwaitwriters; int rw_refcount; // 0 >0 ==-1}my_pthread_rwlock_t; #define RW_MAGIC 0x20180326#define EBUSY 22#define MY_PTHREAD_RWLOCK_INITIALIZER {PTHREAD_MUTEX_INITIALIZER, PTHREAD_COND_INITIALIZER, PTHREAD_COND_INITIALIZER,\RW_MAGIC,0,0,0} typedef int my_pthread_rwlockattr_t; int my_pthread_rwlock_init(my_pthread_rwlock_t *rw, my_pthread_rwlockattr_t *attr);int my_pthread_rwlock_destroy(my_pthread_rwlock_t *rw);int my_pthread_rwlock_rdlock(my_pthread_rwlock_t *rw);int my_pthread_rwlock_wrlock(my_pthread_rwlock_t *rw);int my_pthread_rwlock_unlock(my_pthread_rwlock_t *rw);int my_pthread_rwlock_tryrdlock(my_pthread_rwlock_t *rw);int my_pthread_rwlock_trywrlock(my_pthread_rwlock_t *rw); //my_pthread_rwlock.c #include"my_pthread_rwlock.h"#include<pthread.h>int my_pthread_rwlock_init(my_pthread_rwlock_t *rw, my_pthread_rwlockattr_t *attr){ int result; if(attr != NULL) return -1; if((result = pthread_mutex_init(&rw->rw_mutex,NULL))!=0) goto err1; if((result = pthread_cond_init(&rw->rw_condreaders,NULL))!=0) goto err2; if((result = pthread_cond_init(&rw->rw_condwriters,NULL))!=0) goto err3; rw->rw_nwaitreaders=0; rw->rw_nwaitwriters=0; rw->rw_refcount=0; rw->rw_magic=RW_MAGIC; return 0;err3:pthread_cond_destory(&rw->rw_condreaders);err2:pthread_mutex_destory(&rw->rw_mutex);err1:return result; }int my_pthread_rwlock_rdlock(my_pthread_rwlock_t *rw){ int result; if(rw->rw_magic != RW_MAGIC) return -1; if((result = pthread_mutex_lock(&rw->rw_mutex)) != 0) return result; while(rw->rw_refcount<0 || rw->rw_nwaitwriters>0) { rw->rw_nwaitreaders++; result = pthread_cond_wait(&rw->rw_condreaders, &rw->rw_mutex); rw->rw_nwaitreaders--; if(result != 0) break; } if(result == 0) rw->rw_refcount++; pthread_mutex_unlock(&rw->rw_mutex); return result;} int my_pthread_rwlock_wrlock(my_pthread_rwlock_t *rw){ int result; if(rw->rw_magic != RW_MAGIC) return -1; if((result = pthread_mutex_lock(&rw->rw_mutex)) != 0) return result; while(rw->rw_refcount != 0) { rw->rw_nwaitwriters++; result = pthread_cond_wait(&rw->rw_condwriters, &rw->rw_mutex); rw->rw_nwaitwriters--; if(result != 0) break; } if(result == 0) rw->rw_refcount = -1; pthread_mutex_unlock(&rw->rw_mutex); return result;} int my_pthread_rwlock_unlock(my_pthread_rwlock_t *rw){ int result; if(rw->rw_magic != RW_MAGIC) return -1; if((result = pthread_mutex_lock(&rw->rw_mutex)) != 0) return result; if(rw->rw_refcount > 0) rw->rw_refcount--; else if(rw->rw_refcount == -1) rw->rw_refcount = 0; else printf("unlock error.\n"); if(rw->rw_nwaitwriters > 0) { if(rw->rw_refcount == 0) { result = pthread_cond_signal(&rw->rw_condwriters); } } else if(rw->rw_nwaitreaders > 0) result = pthread_cond_broadcast(&rw->rw_condreaders); pthread_mutex_unlock(&rw->rw_mutex); return result;}int my_pthread_rwlock_tryrdlock(my_pthread_rwlock_t *rw){ int result; if(rw->rw_magic!=RW_MAGIC) return -1; if((result = pthread_mutex_lock(&rw->rw_mutex))!=0) return result; if(rw->rw_refcount!=0||rw->rw_nwaitwriters>0) result=EBUSY; else rw->rw_refcount++; return result;}int my_pthread_rwlock_trywrlock(my_pthread_rwlock_t *rw){ int result; if(rw->rw_magic!=RW_MAGIC) return -1; if((result = pthread_mutex_lock(&rw->rw_mutex))!=0) return result; if(rw->rw_refcount!=0) result=EBUSY; else rw->rw_refcount=-1; pthread_mutex_unlock(&rw->rw_mutex); return result;}int my_pthread_rwlock_destroy(my_pthread_rwlock_t *rw){ if(rw->rw_magic!=RW_MAGIC) return -1; if(rw->rw_refcount!=0||rw->rw_nwaitreaders!=0||rw->rw_nwaitwriters!=0) return EBUSY; pthraed_mutex_destory(&rw->rw_mutex); pthread_cond_destory(&rw->rw_condreaders); pthread_cond_destory(&rw->rw_condwriters); rw->rw_magic=0; return 0; } //main.c #include<stdio.h>#include<unistd.h>#include<pthread.h>#include"my_pthread_rwlock.h" my_pthread_rwlock_t rwlock = MY_PTHREAD_RWLOCK_INITIALIZER; void * thread_fun1(void *arg){}void * thread_fun2(void *arg){}void * thread_fun3(void *arg){ } int main(){ pthread_t tid1, tid2, tid3; pthread_create(&tid1, NULL, thread_fun1, NULL); sleep(1); pthread_create(&tid2, NULL, thread_fun2, NULL); pthread_create(&tid3, NULL, thread_fun3, NULL); pthread_join(tid1, NULL); pthread_join(tid2, NULL); pthread_join(tid3, NULL); return 0;}
复制代码

23.fork 接口说明?

函数原型如下:

#include <fork.h>// 必须引入头文件,使用fork函数的时候,必须包含这个头文件,否则,系统找不到fork函数pid_t fork(void); //void代表没有任何形式参数
复制代码


fork 这个函数最让初学者费解和感到神奇的地方是一次调用,两次返回。一次是在调用进程(也就是派生出子进程的父进程)中返回,返回值是新派生的进程的进程 ID。一次是在子进程中返回,返回值是 0,代表当前进程为子进程。如果返回值为-1 的话,则代表在派生新进程的过程中出错。

fork函数不需要任何参数,对于返回值有三种情况:

1)对于父进程,fork 函数返回新建子进程的 pid;

2)对于子进程,fork 函数返回 0;

3)如果出错, fork 函数返回 -1。

我们可以根据 fork 的返回值来判断当前进程是父进程还是子进程,来实现一些具体的操作。例如:

int main()  {      pid_t pid;      if((pid = fork()) = 0)      {          // TODO: 在子进程中实现具体操作          // ...          exit(0); // 结束子进程      }        // TODO: 在调用进程(父进程)实现具体操作  }  
复制代码

fork函数出错的情况:

fork 函数返回值为-1 即创建失败,有两种情况可能会导致 fork 函数出错:

  1. 系统中已经有太多的进程存在;

  2. 调用 fork 函数的用户创建的进程太多(内核限制了每个进程的子进程数)。

24.fork 的实质过程?

父进程中在调用 fork()派生新进程,实际上相当于创建了进程的一个拷贝即在 fork()之前的进程拥有的资源会被复制到新的进程中去。网络服务器在处理并发请求时,也可以采取这种派生新进程的方式: 父进程调用 accept()后调用 fork()来处理每一个连接。那么,所接受的已连接的套接口随后就在父子进程中共享。通常来说,子进程会在这连接套接口中读和写操作,父进程则关闭这个已连的套接口。

25.fork 注意事项?

在 fork 之后是父进程先执行还是子进程先执行是不确定的。这取决于内核所使用的调度算法,如果要求父,子进程之间相互同步,则要求某种形式的进程间通信。

26.fork 的用法?

fork()有两个典型用法:

(1)一个进程进行自身的复制,这样每个副本可以独立的完成具体的操作,在多核处理器中可以并行处理数据。这也是网络服务器的其中一个典型用途,多进程处理多连接请求。

(2)一个进程想执行另一个程序。比如一个软件包含了两个程序,主程序想调起另一个程序的话,它就可以先调用 fork 来创建一个自身的拷贝,然后通过 exec 函数来替换成将要运行的新程序。

27.了解 fork 的写时复制技术?

写时复制(copy-on-write)是一种可以推迟甚至避免复制无用数据的技术。内核在创建子进程时并不是复制整个父进程的资源,而是让子进程和父进程共享同一个资源副本。只有在需要写入的时候,数据才会被复制,从而使父进程、子进程拥有各自的副本。也就是说,资源的复制只有在需要写入的时候才进行,在此之前以只读方式共享。这种技术使得对地址空间中的页的复制被推迟到实际发生写入的时候。有时共享页根本不会被写入,例如,fork()后立即调用 exec(),就无需复制父进程的页了。fork()实际开销就是给子进程创建唯一的 PCB 然后复制父进程的页表。这种优化可以避免复制大量根本就不会使用的数据。


28.fork 笔试题?

题目:请问下面的程序一共输出多少个“-”?

#include <stdio.h>#include <unistd.h> int main(void){   int i;   for(i=0; i<2; i++){      fork();      printf("-");   }    return 0;}
复制代码

如果你对 fork()的机制比较熟悉的话,这个题并不难,输出应该是 6 个“-”,但是,实际上这个程序会很 tricky 地输出 8 个“-”。

要讲清这个题,我们首先需要知道 fork()系统调用的特性。

fork()系统调用是 Unix 下以自身进程创建子进程的系统调用,一次调用,两次返回,如果返回是 0,则是子进程,如果返回值>0,则是父进程(返回值是子进程的 pid),这是众为周知的。

还有一个很重要的东西是,在 fork()的调用处,整个父进程空间会原模原样地复制到子进程中,包括指令,变量值,程序调用栈,环境变量,缓冲区,等等。

所以,上面的那个程序为什么会输入 8 个“-”,这是因为 printf(“-”);语句,是将打印输出到标准输出流(stdout),标准输出流是基于行缓冲机制的,就是会所只有打印遇到换行符时才会输出。

所以,对于上述程序,printf(“-”);把“-”放到了缓存中,并没有真正的输出,在 fork 的时候,缓存被复制到了子进程空间,所以,就多了两个,就成了 8 个,而不是 6 个。 用 A 表示进程,上面的两轮循环过程如下:

                 Ai=0 	  A(-)       A1(-)i=1   A(--) A2(--)  A1(--) A3(--)
复制代码

最后一轮循环括号内‘-’的个数即为结果。

我们如果修改一下上面的 printf 语句为:

printf("-\n");
复制代码

或是:

printf("-");flush();
复制代码

就没有问题了,因为程序遇到“\n”或是 EOF,或是缓中区满,或是文件描述符关闭,或是主动 flush,都会把数据刷出缓冲区。

29.线程同步方式条件变量若干问题?

注意本文不是教你条件变量的使用,而是探讨条件变量的一些难点问题。

mutex 体现的是一种竞争,我离开了,通知你进来。

cond 体现的是一种协作,我准备好了,通知你开始吧。

互斥锁一个明显的缺点是它只有两种状态:锁定和非锁定。而条件变量通过允许线程阻塞和等待另一个线程发送信号解除阻塞的方法弥补了互斥锁的不足,它常和互斥锁一起配合使用。

使用时,条件变量被用来阻塞一个线程,当条件不满足时,条件变量会解开相应的互斥锁并等待条件发生。一旦其他的某个线程触发了条件变量(相当于条件达成,给阻塞在该条件变量的线程发送解除阻塞信号),一个或多个正被此条件变量阻塞的线程将会被唤醒。条件变量将重新锁定互斥锁,线程需要重新测试条件满足才能进入临界区,退出临界区时线程需要解锁(因为进来之时条件变量已经自动帮我们上了锁)。一般说来,条件变量被用来进行线程间的同步

这里头有几个重要问题。

0)传入前为何要加锁

传入前加锁是为了保证线程从条件判断到进入 pthread_cond_wait 前,条件不被其他线程改变,因为条件判断通常是借助线程共享变量,所以条件也属于临界资源。这个问题也顺带回答了第 4 个问题:为何条件变量要配合互斥锁使用。

1)条件不满足时,为何要解锁(该动作由 pthread_cond_wait 自动完成)?

条件不满足时,pthread_cond_wait 解锁之后就进入阻塞状态。如果不解锁,会导致其他线程无法修改判断条件,从而导致死锁。例如工人 A 会去查询仓库库存本子,本子记录存货量,发现无货就睡觉,有货就发货。工人 B 负责进货,每进一批货都要在本子上记录。如果 A 睡觉拿着本子,那么 B 就无法登记了。这个本子就表示判断条件。

2)线程被唤醒之后,为何需要加锁呢?(注意这个加锁的动作由 pthread_cond_wait 自动完成)

进入临界区自然需要加锁。仓库本子同一时刻只能由一个人使用,否则数据错乱。

3)线程被唤醒并加锁之后,为何还需要重新测试条件是否满足呢?

虽然线程被唤醒,意味着条件已经满足。但是由于线程被唤醒和重新加锁这个时间间隙内,可能有其他线程抢先进入了临界区,即破坏了测试条件,所以我们还需要验证这个条件是否真的成立。


所以我们通常的写法是一个 while 循环:

    pthread_mutex_lock(&count_lock);    while(count == 0)    {        pthread_cond_wait(&count_nonzero, &count_lock);//while循环使得退出之后再次判断条件是否成立    }    count -= 1;    pthread_mutex_unlock(&count_lock);
复制代码


4)退出临界区时为何要解锁(这个动作需要我们完成)?

因为进来之时条件变量已经自动帮我们上了锁,但这个解锁动作要由我们自己完成。

5)为何条件变量要配合互斥锁使用?

考虑不加锁的场景:

while(count == 0){      pthread_cond_wait(&count_nonzero);//while循环使得退出之后再次判断条件是否成立}
复制代码

count 作为一种多线程共享的判断条件,它本身就是一个竞争资源,所以自然需要互斥锁来保证对它的访问和修改在某一时刻只有一个线程在操作,避免因条件判断语句与其后的正文或 wait 语句之间的间隙而产生的漏判或误判。条件本身就是一个竞争资源,这个资源的作用是对其后程序正文的执行权,于是用一个锁来保护。这样就关闭了条件检查和线程进入休眠状态等待条件改变这两个操作之间的时间通道,这样线程就不会有任何变化。

我们要理解条件变量的作用是在等待某个条件达成时自身要进行睡眠或阻塞,避免忙等待带来的不必要消耗,所以条件变量的作用在于同步。条件变量这个变量其实本身不包含条件信息,条件的判断不在 pthread_cond_wait 函数功能中,而需要外面进行条件判断。这个条件通常是多个线程或进程的共享变量(在这里是 count),这样就很清楚了,对于共享变量很可能产生竞争条件尤其还对共享变量加了条件限制,所以从这个角度看,必须对共享变量加上互斥锁。

6)条件变量和互斥锁的结合使用提高程序效率

两个线程操作同一临界区时,通过互斥锁保护,若 A 线程已经加锁,B 线程再加锁时候会被阻塞,直到 A 释放锁,B 再获得锁运行,进程 B 必须不停的主动获得锁、检查条件、释放锁、再获得锁、再检查、再释放,一直到满足运行的条件的时候才可以(而此过程中其他线程一直在等待该线程的结束),这种方式是比较消耗系统的资源的。


而条件变量同样是阻塞,还需要通知才能唤醒,线程被唤醒后,它将重新检查判断条件是否满足,如果还不满足,重新进入阻塞状态,等待条件满足后被唤醒,节省了线程不断运行浪费的资源。这个过程一般用 while 语句实现。当线程 B 发现被锁定的变量不满足条件时会自动的释放锁并把自身置于等待状态,让出 CPU 的控制权给其它线程。其它线程此时就有机会去进行操作,当修改完成后再通知那些由于条件不满足而陷入等待状态的线程。这是一种通知模型的同步方式,大大的节省了 CPU 的计算资源,减少了线程之间的竞争,而且提高了线程之间的系统工作的效率。这种同步方式就是条件变量。


以上说明可能有点抽象,考虑这样的简单场景:通过伪代码说明。


A 线程从队列中取元素,B 线程往队列中存放元素。不考虑免锁的实现。需要一个 mutex 用来保护队列的一致性,避免两个线程同时操作队列破坏数据结构。


当队列为空的时候,A 需要不断的探测队列状态:

while(1){     if(队列为空)        sleep(10);//睡眠10秒    else    {        lock();        取元素;        unlock();     }}
复制代码

这就有一个问题,可能 A 在刚进入休眠时,B 放入元素了,但 A 仍然需要休眠完整个 10s 的时间,造成不必要的延迟。当然如果不 sleep,也可以,但会造成不必要的 CPU 开销(当队列为空时,线程 A 一直在做无用的重复动作:检查队列是否空,结果非空,继续检查)。


使用基于条件变量的事件通知唤醒机制,就可以避免这些问题。一旦 B 放入元素完成后就执行 pthreadcondsignal(),当前阻塞的线程就 A 会立即被唤醒开始干活儿。


7)pthread_cond_signal 唤醒的两种写法

1)在锁里发送唤醒信号

pthread_cond_signal的两种写法lock(&mutex);//一些操作pthread_cond_signal(&cond);//一些操作unlock(&mutex);
复制代码

缺点:在某些线程的实现中,会造成等待线程从内核中唤醒(由于 cond_signal)回到用户空间,然后 pthread_cond_wait 返回前需要加锁,但是发现锁没有被释放,又回到内核空间所以一来一回会有性能的问题。


但是在 LinuxThreads 或者 NPTL 里面,就不会有这个问题,因为在 Linux 线程中,有两个队列,分别是 cond_wait 队列和 mutex_lock 队列, cond_signal 只是让线程从 cond_wait 队列移到 mutex_lock 队列,而不用返回到用户空间,不会有性能的损耗。所以 Linux 中这样用没问题。


2)解锁之后再唤醒

lock(&mutex);//一些操作unlock(&mutex);pthread_cond_signal(&cond);
复制代码

优点:不会出现之前说的那个潜在的性能损耗,因为在 signal 之前就已经释放锁了。


缺点:如果 unlock 之后 signal 之前,发生进程交换,另一个进程(不是等待条件的进程)拿到这把梦寐以求的锁后加锁操作,那么等最终切换到等待条件的线程时锁被别人拿去还没归还,只能继续等待。在只有 1 个生产者的情况下不会有这个问题。

总结

条件变量常和互斥锁一起使用,条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足。使用时,条件变量被用来阻塞一个线程,当条件不满足时,线程往往解开相应的互斥锁并等待条件发生变化。一旦其它的某个线程改变了条件变量,它将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。这些线程将重新锁定互斥锁并重新测试条件是否满足。


pthread_cond_wait()需要传入一个已经加锁的互斥锁,该函数把调用线程加入等待条件的调用列表中,然后释放互斥锁,在条件满足从而离开 pthread_cond_wait()时,mutex 将被重新加锁,这两个函数是原子操作。可以消除条件发生和线程睡眠等待条件发生间的时间间隙。其他线程在获得互斥锁之前不会察觉到这种改变,因为必须锁定互斥锁才能计算条件。


条件变量用于某个线程需要在某种条件成立时才进入它将要操作的临界区,这种情况从而避免了线程不断轮询检查该条件是否成立而降低效率的情况,这是实现了效率提高。。。在条件满足时,自动退出阻塞,再加锁进行操作。


30.上下文基本概念?

我们在介绍进程、线程、中断时常常提及上下文的概念,那么什么是上下文。这是个面试提问概率比较大的问题,对于一个嵌入式 linux 软件系统,时刻都在进行着用户空间和内核空间的相互切换,以及进程间的轮转调度,时不时还会来个中断,进入到中断处理程序然后再返回。在这切换的过程中,我们需要保存进程在用户空间的状态,以便切换回去时恢复原来状态,由此引入了上下文的概念。


进程上下文和中断上下文是操作系统中很重要的两个概念,这两个概念在操作系统课程中不断被提及,是最经常接触、看上去很懂但又说不清楚到底怎么回事。造成这种局面的原因,可能是原来接触到的操作系统课程的教学总停留在一种浅层次的理论层面上,没有深入去研究。


处理器总处于以下状态中的一种:

1、内核态,运行于进程上下文,内核代表进程运行于内核空间;

2、内核态,运行于中断上下文,内核代表硬件运行于内核空间;

3、用户态,运行于用户空间。


其实,除了上面三种状态之外,还有一种就是永远处于内核态的内核线程,内核也有自己的任务需要处理,这类内核线程有一部分完全运行于内核空间,它们也有自己的上下文,所以我们把这种状态归纳为运行于内核线程上下文吧。


通过执行ps命令,可以看到有些进程名以[]括起来,这些就是内核线程,它们负责完成特定的任务,像我们后面会介绍的2号进程或者说内核线程kthreadd。


用户空间的应用程序,通过系统调用(当然触发异常也会),进入内核空间。这个时候用户空间的进程要传递很多变量、参数的值给内核,内核态运行的时候也要保存用户进程的一些寄存器值、变量等。所谓的“进程上下文”,可以看作是用户进程传递给内核的这些参数以及内核要保存的那一整套的变量和寄存器值和当时的环境等。

硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。所谓的“中断上下文”,其实也可以看作就是硬件传递过来的这些参数和内核需要保存的一些其他环境(主要是当前被打断执行的进程环境)。


关于进程上下文 LINUX 完全注释中的一段话:


当一个进程在执行时,CPU 的所有寄存器中的值、进程的状态以及堆栈中的内容被称为该进程的上下文。当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的上下文,以便在再次执行该进程时,能够必得到切换时的状态执行下去。在 LINUX 中,当前进程上下文均保存在进程的任务数据结构中。在发生中断时,内核就在被中断进程的上下文中,在内核态下执行中断服务例程。但同时会保留所有需要用到的资源,以便中断服务结束时能恢复被中断进程的执行。


内核空间和用户空间是操作系统理论的基础之一,即内核功能模块运行在内核空间,而应用程序运行在用户空间。现代的 CPU 都具有不同的操作模式,代表不同的级别,不同的级别具有不同的功能,在较低的级别中将禁止某些操作。Linux 系统设计时利用了这种硬件特性,使用了两个级别,最高级别和最低级别,内核运行在最高级别(内核态),这个级别可以进行所有操作,而应用程序运行在较低级别(用户态),在这个级别,处理器控制着对硬件的直接访问以及对内存的非授权访问。内核态和用户态有自己的内存映射,即自己的地址空间。


正是有了不同运行状态的划分,才有了上下文的概念。用户空间的应用程序,如果想要请求系统服务,比如操作一个物理设备,或者映射一段设备空间的地址到用户空间,就必须通过系统调用来(操作系统提供给用户空间的接口函数)实现。


通过系统调用,用户空间的应用程序就会进入内核空间,由内核代表该进程运行于内核空间,这就涉及到上下文的切换,用户空间和内核空间具有不同的地址映射,通用或专用的寄存器组,而用户空间的进程要传递很多变量、参数给内核,内核也要保存用户进程的一些寄存器、变量等,以便系统调用结束后回到用户空间继续执行,所谓的进程上下文,就是一个进程在执行的时候,CPU 的所有寄存器中的值、进程的状态以及堆栈中的内容,当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。


同理,硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理,中断上下文就可以理解为硬件传递过来的这些参数和内核需要保存的一些环境,主要是被中断的进程的环境。


Linux 内核工作在进程上下文或者中断上下文。提供系统调用服务的内核代码代表发起系统调用的应用程序运行在进程上下文;另一方面,中断处理程序,异步运行在中断上下文。中断上下文和特定进程无关。


运行在进程上下文的内核代码是可以被抢占的(Linux2.6 支持抢占)。但是一个中断上下文,通常都会始终占有 CPU(当然中断可以嵌套,但我们一般不这样做),不可以被打断。正因为如此,运行在中断上下文的代码就要受一些限制,不能做下面的事情:


  • 1、睡眠或者放弃 CPU,这样做的后果是灾难性的,因为内核在进入中断之前会关闭进程调度,一旦睡眠或者放弃 CPU,这时内核无法调度别的进程来执行,系统就会死掉;

  • 2、尝试获得信号量 如果获得不到信号量,代码就会睡眠,会产生和上面相同的情况;

  • 3、执行耗时的任务 中断处理应该尽可能快,因为内核要响应大量服务和请求,中断上下文占用 CPU 时间太长会严重影响系统功能;

  • 4、访问用户空间的虚拟地址 因为中断上下文是和特定进程无关的,它是内核代表硬件运行在内核空间,所以在中断上下文无法访问用户空间的虚拟地址。


关于中断里不能做什么事也是linux驱动开发面试官经常问及的问题。


31.上下文切换与多处理器?


于是进程有两个幻觉:一认为自己独享内存;二以为自己独享处理器。我们对于一台机器上的多个进程的幻觉是感觉他们是同时运行。


我们来依次解释下上面的三个幻觉:


关于独享内存不是我们的重点,简单说说。独享内存是指我们每个进程都独享虚拟内存。而虚拟内存地址最终是通过 MMU 翻译成实际的物理地址。这样做只是为了提供一种逻辑上的连续性,屏蔽内存碎片或是规避因内存有限而扩展到硬盘的各种问题,这样不用考虑实际的的限制从而使应用程序开发变得容易。还有一个值得注意的问题是在这个虚拟内存中如果这个进程是多线程的,那么将共享改空间,除了各自的栈、寄存器和所谓的虚拟处理器(LWP)。这样会导致一个问题就是多个线程的 stacksize 对进程内存空间的要求呈线性增长,与复杂的多层级递归运算类似,导致 stackoverflow。这也是好多语言比如 Java 的线程模型要求线程创建时指定好 stacksize 大小的原因。


作者的原话是:“这样会导致一个问题就是多个线程的stacksize对进程栈空间的要求呈线性增长”。我们知道线程必须要有自己独立的栈空间,不然它的局部变量放哪,函数调用时也要进行入栈出栈操作。大家有没有思考过线程的栈位于哪里?大小固定吗?如果固定有多大?实际上线程的栈和进程的栈并没有放在一起,线程的栈在创建线程的时候通过mmap系统调用分配,默认大小是8M,可以由用户通过设置线程属性来设定,当然系统规定了线程栈的最大值,可以通过ulimit命令来查询和设置。由于使用mmap系统调用分配,我们就知道线程的栈位于哪里了(位于进程堆和栈之间的动态映射区)。


关于线程的栈探究,我会另写一篇,这是个十分有趣的问题。


以前是单处理器的机器,后来通过单纯的提高处理器主频,已经无法明显提升系统整体性能。实在没办法了,科学家们就想啊想啊,就相出了多核处理器。这样的话处理线程级的多任务,就可以实现真正的并行了。但问题是处理器的核数远小于需要并行的任务数量。有许多因素都客观限制处理器核数。那要完成多个进程同时执行的幻觉就只能通过来回的轮番执行,快速切换。这就到上下文切换的话题了。


关于上下文切换我仅仅参考 linux 内核的实现从技术角度来解释:


在 linux 中一个叫做 task_struct 结构体代表一个进程,linux 调度器会对一个结构体:sched_entity 结构体感兴趣并对其进行调度,而它正好嵌入到 task_struct 中。那具体怎么调度呢?


Linux 用红黑树存所有可运行的进程(注意是可运行),使用等待队列 wait_queue 记录休眠(被阻塞)线程。用一个例子来介绍调度和上下文切换的细节,例如网卡产生一个中断通知有网络数据,执行中的线程阻塞(从执行状态剥离并放入等待队列),然后再到红黑树里面选一个来执行。这个过程的详细过程是:虚拟内存映射和处理器状态均要切换到新线程,前一个线程寄存器、栈信息还有其他状态信息被保存。而新线程的栈信息和寄存器信息被恢复,刚好是反操作。我们把上述过程叫做上下文切换。等到网络数据读取就绪,在等待队列中的线程又被唤醒,接着放入红黑树中,成为可执行态,等待被执行。


多处理器就是一台机器具有多个处理器。它的主流架构叫做对称多处理器(SMP),这些处理器共享内存,共用一个系统,程序可以并行执行在每一个处理器上。拿多核处理器来说,通过一个核心执行一个线程,操作系统通过指令分派让一个核心负责一个程序执行,达到真正意义上的并行。目前的手机尤其是 android 手机通过添加核心数来提升运行速度。这确实可以得到提高。但是在软件角度还受到几方面限制:一是调度算法针对核心数优化,以充分利用多核优势;二是程序的并行性,如果程序是单线程再多核同时也只能跑在一个上面,其他的却白白浪费;还有就是,增加核心数和处理能力并非成线性关系。

总结


上下文 context: 上下文简单说来就是一个环境。


用户空间的应用程序,通过系统调用,进入内核空间。这个时候用户空间的进程要传递 非常多变量、參数的值给内核。内核态执行的时候也要保存用户进程的一些寄存器值、变量等。所谓的“进程上下文”,能够看作是用户进程传递给内核的这些參数以及内核要保存的那一整套的变量和寄存器值和当时的环境等。


相对于进程而言,就是进程运行时的环境。详细来说就是各个变量和数据,包含全部的寄存器变量、进程打开的文件、内存信息等。一个进程的上下文能够分为三个部分:用户级上下文、寄存器上下文以及系统级上下文。


  • (1)用户级上下文: 正文、数据、用户堆栈以及共享存储区。

  • (2)寄存器上下文

  • (3)系统级上下文: 内核用于描述一个进程的数据结构,例如:进程控制块 task_struct、内存管理信息(mm_struct、vm_area_struct、pgd、pte)、内核栈。


本文引入很多的问题,比如中断有栈吗?它的栈在哪里?进程的上下文如何切换?寄存器上下文有哪些寄存器需要保存,是全部吗?发生系统调用时用户空间如何向内核空间传递参数?...


什么是进程上下文、中断上下文?是面试中经常提到的问题。。。

32.Linux 下线程同步和互斥的方式有哪些?

0.互斥锁


通过锁机制实现线程间的同步。


初始化锁

在 Linux 下,线程的互斥量数据类型是 pthread_mutex_t。在使用前,要对它进行初始化。

静态分配:pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

动态分配:int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutex_attr_t *mutexattr);

加锁

对共享资源的访问,要对互斥量进行加锁,如果互斥量已经上了锁,调用线程会阻塞,直到互斥量被解锁。

int pthread_mutex_lock(pthread_mutex *mutex);int pthread_mutex_trylock(pthread_mutex_t *mutex);
复制代码


解锁

在完成了对共享资源的访问后,要对互斥量进行解锁。

int pthread_mutex_unlock(pthread_mutex_t *mutex);

销毁锁。锁在是使用完成后,需要进行销毁以释放资源。

int pthread_mutex_destroy(pthread_mutex *mutex);

1.条件变量(cond)

互斥锁不同,条件变量是用来等待而不是用来上锁的。条件变量用来自动阻塞一个线程,直到某特殊情况发生为止。通常条件变量和互斥锁同时使用。条件变量分为两部分: 条件和变量。条件本身是由互斥锁保护的。线程在改变条件状态前先要锁住互斥锁。条件变量使我们可以睡眠等待某种条件出现。条件变量是利用线程间共享的全局变量进行同步的一种机制,主要包括两个动作:一个线程等待"条件变量的条件成立"而挂起;另一个线程使"条件成立"(给出条件成立信号)。条件的检测是在互斥锁的保护下进行的。如果一个条件为假,一个线程自动阻塞,并释放等待状态改变的互斥锁。如果另一个线程改变了条件,它发信号给关联的条件变量,唤醒一个或多个等待它的线程,重新获得互斥锁,重新评价条件。如果两进程共享可读写的内存,条件变量可以被用来实现这两进程间的线程同步。

初始化条件变量

静态态初始化,pthread_cond_t cond = PTHREAD_COND_INITIALIER;

动态初始化,int pthread_cond_init(pthread_cond_t *cond, pthread_condattr_t *cond_attr);

等待条件成立

释放锁,同时阻塞等待条件变量为真才行。timewait()设置等待时间,仍未 signal,返回 ETIMEOUT

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);int pthread_cond_timewait(pthread_cond_t *cond,pthread_mutex *mutex,const timespec *abstime);
复制代码

激活条件变量

pthread_cond_signal,pthread_cond_broadcast(激活所有等待线程)

int pthread_cond_signal(pthread_cond_t *cond);int pthread_cond_broadcast(pthread_cond_t *cond); //解除所有线程的阻塞
复制代码

销毁条件变量

无线程等待,否则返回 EBUSY

int pthread_cond_destroy(pthread_cond_t *cond);

3.信号量(sem)


如同进程一样,线程也可以通过信号量来实现通信,虽然是轻量级的。信号量函数的名字都以"sem_"打头。线程使用的基本信号量函数有四个。


信号量初始化

int sem_init (sem_t *sem , int pshared, unsigned int value);

这是对由 sem 指定的信号量进行初始化,设置好它的共享选项(linux 只支持为 0,即表示它是当前进程的局部信号量),然后给它一个初始值 VALUE。

占用 or 等待信号量

如果信号量值大于 1,则给信号量减 1;如果信号量值小于 1,则等待直到信号量的值大于 0 再获取信号量,给信号量减 1。

int sem_wait(sem_t *sem);

释放信号量

信号量值加 1。并通知其他等待线程。

int sem_post(sem_t *sem);

销毁信号量

我们用完信号量后都它进行清理。归还占有的一切资源。

int sem_destroy(sem_t *sem);


二元信号量

是最简单的一种锁,它只用两种状态:占用与非占用。信号量的取值范围为[0,1],适合同一时刻只能被一个进程或者线程独占访问的资源。

3.理解 PV 操作


PV 操作由 P 操作原语和 V 操作原语组成(原语是不可中断的过程),对信号量进行操作。


P 操作主要做 2 件事情:

给信号量值减 1

判断值是否小于 0,如果值小于 0,那么调用 P 操作的进程的状态就变成阻塞状态,并且把进程送到相应的等待队列的末尾,cpu 重新调度。

如果值不小于 0,那么实施 P 操作的进程继续执行。


V 操作:

给信号量值加 1

判断值是否小于等于 0,如果小于等于 0,那么就唤醒信号量队列上等待的一个进程,将其状态改变为就绪状态,并插入就绪队列。

P 即 down。

V 即 up。


问题汇总

1、简述条件变量的机制?

2、说下信号量的 PV 操作?

3、什么是二元信号量?

4、信号量和互斥锁的区别?

  • 信号量可以被任意线程获取并释放,而互斥锁谁加锁就必须由谁来释放

  • 信号量可以同于进程和线程,而互斥锁只能用于线程。

  • 信号量强调的是同步,互斥锁强调的是资源的独占访问,同步往往做到了互斥(关于同步和互斥的概念见其他文章)。

5、线程同步互斥方式有哪些?


发布于: 2020 年 11 月 02 日阅读数: 102
用户头像

是哒宰丫 2020.10.30 加入

嵌入式软件工程师,Linux驱动工程师

评论

发布
暂无评论
嵌入式面试之《Linux系统编程100问》