操作系统之进程管理

文章目录

1.进程描述

1.1 程序并发执行的特征

  • 间断性:共享和同步制约导致,执行-暂停-执行。
  • 失去封闭性:资源状态由多程序改变。
  • 不可再现性:相同环境和初始条件,重复执行结果不同。
  • 进程和程序的区别

1.2 进程的结构

1.2.1 进程定义和结构

  • 定义:进程是进程实体的运行过程,是系统资源分配的基本单位。
  • 进程结构:由PCB(进程控制块)、代码段、数据段组成,其中PCB是进程存在的唯一标识。系统如果同时运行多个相同的程序,代码段相同,堆栈段(Linux环境)和数据段是不同的(相同的程序,处理的数据不同)。

1.2.2 PCB结构

  • 进程标识信息:包括进程标识符PID和用户标识符UID。
  • 进程处理机状态信息:包括通用寄存器,程序计数器PC,程序状态字PSW,堆栈指针等等。
  • 进程控制和调度信息:包括进程调度信息,进程间通信信息,存储管理信息,进程所用资源等等。

1.2.3 PCB初始化(与PCB结构对应)

  • 初始化进程标识信息。
  • 初始化进程处理机状态信息。
  • 初始化进程控制和调度信息。

1.2.4 PCB组织方式

  • 链表:把相同状态的PCB链接成链表,可以更好完成动态插入和删除
  • 索引表:建立就绪索引表,阻塞索引表等等,把索引表的首地址放在专用区域。
  • 线性表:将系统所有的PCB都组织在同一张线性表中,将该表的首地址放在专用区域。

1.3 进程基本特征

  • 动态性:最基本特征,动态创建和结束进程。
  • 并发性:多个进程在同一内存,同段时间内并发执行。
  • 独立性:不同的进程之间的工作没有相互影响。
  • 异步性:进程按照独立的,不可预知的速度向前推进。

1.4 进程基本状态

  • 就绪态:进程已经分配到除CPU以外的所有资源,只要获得CPU立即执行。
  • 执行态:进程获得CPU,进程正在执行。
  • 阻塞态:正在执行的进程由于某时间(例如I/O请求)暂时无法继续执行。
  • 创建态:进程在创建时需要申请一个空白PCB,完成资源分配。
  • 终止态:进程结束,或出现错误,或被系统终止,已经无法执行。
  • 图示五种状态转换

2.进程的控制

2.1 进程的创建

2.1.1 引起进程创建的事件

  • 系统初始化。
  • 用户请求创建新的进程。
  • 正在执行的进程执行了创建进程的系统调用。

2.1.2 进程创建过程

  • 申请空白PCB。
  • 为新进程分配资源。
  • 初始化进程控制块(PCB)。
  • 将新进程插入到就绪队列中。

2.2 进程的执行

  • 内核选择一个就绪的进程,让它占用处理机并且执行。

2.3 进程的阻塞

2.3.1 引起进程阻塞的事件

  • 请求并等待系统服务,无法马上完成。
  • 启动某种操作,无法马上完成。
  • 需要的数据还没到达

2.3.2 进程阻塞的过程

  • 保存当前进程的CPU现场。
  • 设置该进程为阻塞态,进入阻塞队列。
  • 转进程调度。

2.4 进程的唤醒

2.4.1 引起进程唤醒的事件

  • 被阻塞进程等待的事件到达。
  • 被阻塞进程需要的资源可以被满足。
  • 将该进程的PCB插入到就绪队列。

2.4.2 进程唤醒的过程

  • 从阻塞队列中摘下被唤醒的进程。
  • 设置该进程为就绪态,进入就绪队列。
  • 转进程调度或者返回。

2.5 进程终止

2.5.1 引起进程终止的事件

  • 正常退出(自愿)。
  • 错误退出(自愿)。
  • 致命错误(强制性)。
  • 被其它进程所杀(强制性)。

2.5.2 进程终止的过程

  • 从PCB集合中检索出该进程PCB,读取该进程的状态信息。
  • 若出于执行状态,终止该进程的执行。
  • 若有子进程,将所有子进程杀死。
  • 将进程全部资源归还父进程或者系统。
  • 将该进程PCB从PCB集合中移除。

2.6 挂起与激活

2.6.1 挂起的两种状态

  • 阻塞挂起状态:进程在外存中并等待某事件的发生。
  • 就绪挂起状态:进程在外存中,但只要进入内存中即可运行。

2.6.2 挂起

  • 阻塞态到阻塞挂起:就绪态要求更多的内存资源时,会进行这种转换,以提交新进程或者运行就绪进程。
  • 就绪态到就绪挂起:当有高优先级阻塞进程和低优先级就绪进程时,系统会选择挂起低优先级就绪进程。
  • 执行态到就绪挂起:对抢先式分时系统,当有高优先级阻塞挂起进程因事件出现而进入就绪挂起时,系统可能会把运行态转到就绪挂起。
  • 阻塞挂起到就绪挂起:当阻塞挂起进程因相关事件发生时,系统会把阻塞挂起转换成就绪挂起。

2.6.3 激活

  • 就绪挂起到就绪:没有就绪进程或者就绪挂起进程优先级高于就绪进程时,会进行这种转换。
  • 阻塞挂起到阻塞:当一个进程释放足够内存时,系统会把一个高优先级阻塞挂起进程转换成阻塞进程。

3.进程上下文切换

3.1 进程上下文切换的背景

  • 内核管理所有PCB,而PCB记录进程全部状态信息。进程在执行时,内核可以抢占当前进程并开始新的进程,这个过程由内核调度器完成。当调度器选择某个进程时称为进程调度,该过程通过上下文切换来改变当前状态。
  • 一次完整的上下文切换通常是进程原先运行于用户态,之后因I/O中断或时间片到切换到内核态执行内核指令,完成上下文切换后回到用户态,此时已经切换到其它进程。

3.2 进程上下文切换的特征

  • 停止当前运行的进程并且调度其它进程
  • 必须在切换前存储进程上下文的相关内容。
  • 必须能够在之后恢复它们,所以进程不能显示曾经被暂停过。
  • 上下文切换十分频繁,必须快速

3.3 进程切换开销

  • 切换页表全局目录。
  • 切换内核态堆栈。
  • 切换硬件上下文(进程恢复前,必须装入寄存器的数据统称为硬件上下文)。
  • 刷新TLB。
  • 系统调度器的代码执行。

4.多进程

4.1 多进程背景

  • 进程结构由代码段、数据段和堆栈段组成。代码段是静态的二进制代码,因此多个程序可以共享代码段。实际上在父进程创建子进程之后,父子进程除了pid外,其它部分部分几乎一样。父子进程共享全部数据,但并不是说他们就是对同一块数据进行操作,子进程在读写数据时会通过写时复制机制将公共的数据重新拷贝一份,之后在拷贝出的数据上进行操作。如果子进程想要运行自己的代码段,还可以通过调用execv()函数重新加载新的代码段,之后就和父进程独立开了。在shell中执行程序就是通过shell进程先fork()一个子进程再通过execv()重新加载新的代码段的过程。

4.2 进程的创建

  • 进程有两种创建方式,一种是操作系统创建的,另外一种是父进程创建的。从计算机启动到终端执行程序的过程为:0号进程 -> 1号内核进程 -> 1号用户进程(init进程) -> getty进程 -> shell进程 -> 命令行执行进程。所以我们在命令行中通过 ./program执行可执行文件时,所有创建的进程都是shell进程的子进程,这也就是为什么shell一关闭,在shell中执行的进程都自动被关闭的原因。
  • int fork();返回值:如果出错返回-1;在调用者进程中,返回值是子进程编号;在子进程中,返回值是0。
  • 案例
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
    printf("本程序的进程编号是:%d\n",getpid());
    int ipid=fork();
    sleep(1);       // sleep等待进程的生成。
    printf("pid=%d\n",ipid);
    if (ipid!=0) printf("父进程编号是:%d\n",getpid());
    else printf("子进程编号是:%d\n",getpid());
    sleep(30);    
    // 是为了方便查看进程在shell下用ps -ef|grep main 查看本进程的编号;
}

  • 案例解释:1)一个fork函数返回了两个值?;2)if和else中的代码能同时被执行?原因:fork函数创建了一个新的进程,子进程与父进程一模一样。子进程和父进程使用相同的代码段,子进程拷贝父进程的堆栈段和数据段。子进程一旦开始运行,它复制父进程的一切数据,然后各自运行,相互之间没有影响。fork函数对返回值做了特别的处理,调用fork函数之后,在子进程中fork的返回值是0,在父进程中fork的返回是子进程的编号,程序员可以通过fork的返回值来区分父进程和子进程,然后再执行不同的代码。

4.3 结束进程

  • void exit(int status);,status是退出状态,保存在全局变量中,通常0表示正常退出。
  • 正常退出方式有exit()、_exit()、return(在main中)
  • exit()和_exit()区别:exit()是对__exit()的封装,都会终止进程并做相关收尾工作,最主要的区别是_exit()函数关闭全部描述符和清理函数后不会刷新流,但是exit()会在调用_exit()函数前刷新数据流
  • return和exit()区别:exit()是函数,有参数,执行完之后控制权交给系统。return若是在调用函数中,执行完之后控制权交给调用进程,若是在main函数中,控制权交给系统
  • 异常退出方式有abort()、终止信号

4.4 获取进程ID

  • int getpid(void);,返回子进程pidint getppid(void);,返回父进程pid

5.守护进程、僵尸进程和孤儿进程

5.1 守护进程

5.1.1 守护进程定义

  • 守护进程是指在后台运行的而且没有控制终端与之相连的进程。它独立于控制终端,周期性地执行某种任务。Linux的大多数服务器就是用守护进程的方式实现的,如web服务器进程http等。

5.1.2 创建守护进程过程

  • 让程序在后台执行,方法是调用fork产生一个子进程,然后使父进程退出。
  • 调用setsid创建一个新对话期。控制终端、登录会话和进程组通常是从父进程继承下来的,守护进程要摆脱它们,不受它们的影响,方法是调用setsid使进程成为一个会话组长。setsid调用成功后,进程成为新的会话组长和进程组长,并与原来的登录会话、进程组和控制终端脱离
  • 禁止进程重新打开控制终端。经过以上步骤,进程已经成为一个无终端的会话组长,但是它可以重新申请打开一个终端。为了避免这种情况发生,可以通过使进程不再是会话组长来实现。再一次通过fork创建新的子进程,使调用fork的进程退出。
  • 关闭不再需要的文件描述符。子进程从父进程继承打开的文件描述符。如不关闭,将会浪费系统资源,造成进程所在的文件系统无法卸下以及引起无法预料的错误。首先获得最高文件描述符值,然后用一个循环程序,关闭0到最高文件描述符值的所有文件描述符。
  • 将当前目录更改为根目录
  • 进程从父进程继承的文件创建屏蔽字可能会拒绝某些许可权。为防止这一点,使用unmask(0)将屏蔽字清零。
  • 处理SIGCHLD信号。对于服务器进程,在请求到来时往往生成子进程处理请求。如果子进程等待父进程捕获状态,则子进程将成为僵尸进程(zombie),从而占用系统资源。如果父进程等待子进程结束,将增加父进程的负担,影响服务器进程的并发性能。在Linux下可以简单地将SIGCHLD信号的操作设为SIG_IGN。这样,子进程结束时不会产生僵尸进程。

5.2 孤儿进程

  • 如果父进程先退出,子进程还没退出,那么子进程的父进程将变为init进程(注:任何一个进程都必须有父进程)。一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。
  • 孤儿进程将被init进程(pid=1)所收养,并由init进程对它们完成状态收集工作。

5.3 僵尸进程

5.3.1 僵尸进程描述

  • 如果子进程先退出,父进程还没退出,那么子进程必须等到父进程捕获到子进程的退出状态才真正结束,否则这个时候子进程就成为僵尸进程。
  • 设置僵尸进程的目的是维护子进程的信息,以便父进程在以后某个时候获取。这些信息至少包括进程ID,进程的终止状态,以及该进程使用的CPU时间,所以当父进程调用wait或waitpid时就可以得到这些信息。
  • 如果一个进程终止,而该进程有子进程处于僵尸状态,那么它的所有僵尸子进程的父进程ID将被重置为1(init进程)。继承这些子进程的init进程将清理它们(也就是说init进程将wait它们,从而去除它们的僵尸状态)。

5.3.2 如何避免僵尸进程

  • 最常用:通过signal(SIGCHLD, SIG_IGN)通知内核对子进程的结束不关心,由内核回收。如果不想让父进程挂起,可以在父进程中加入一条语句:signal(SIGCHLD,SIG_IGN);表示父进程忽略SIGCHLD信号,该信号是子进程退出的时候向父进程发送的。
  • 父进程调用wait/waitpid等待子进程结束,如果尚无子进程退出wait会导致父进程阻塞。waitpid可以通过传递WNOHANG使父进程不阻塞立即返回。
  • 如果父进程很忙可以用signal注册信号处理函数,在信号处理函数调用wait/waitpid等待子进程退出。
  • 通过两次调用fork。父进程首先调用fork创建一个子进程然后waitpid等待子进程退出,子进程再fork一个孙进程后退出。这样子进程退出后会被父进程等待回收,而对孙进程其父进程已经退出所以孙进程成为一个孤儿进程,孤儿进程由init进程接管,孙进程结束后,init会等待回收。

6.进程同步

6.1 背景知识

  • 同步:多个进程因为合作产生的直接制约关系,使得进程有一定的先后执行关系
  • 互斥:多个进程在同一时刻只有一个进程能进入临界区。
  • 同步与互斥区别:同步需要按照规定的先后顺序来执行。
  • 临界资源一次仅允许一个进程访问的资源。
  • 进入区:检查临界资源是否可以访问
  • 临界区:进程访问临界资源的那段程序,进程必须互斥进入临界区。
  • 退出区:将临界区标志设置为未访问

6.2 信号量

6.2.1 信号量基本描述

  • 一种数据结构,其值与资源的使用有关。
  • 信号量的值仅有PV操作有关。

6.2.2 整型信号量

  • 定义:一个整型的变量作为信号量,用来表示系统中某种资源的数量,只有三种操作:初始化,P操作,V操作。
  • 代码
semaphore s=1;  //初始化整型信号量s,表示当前系统中可用资源数为1;
void wait(int s){//wait原语相当于进入区,检查上锁,避免并发异步导致出现问题;
	while(s<=0); //如果资源不够,就一直等待下去;
	s=s-1;       //如果资源足够,则占用一个资源;
}
void signal(int s){  //signal原语,相当于退出区;
	s=s+1;  //使用资源后释放资源;
}
  • 存在的问题:没有遵循让权等待原则,而是使进程出于忙等状态

6.2.3 记录型信号量

  • 定义:用struct表示信号量数据类型。
struct semaphore{
	int value;    //剩余资源数;
	semaphore *L; //等待队列;
};
  • 代码
//进程需要使用资源时,通过wait原语使用;
void wait(semaphore s){
	s.value--;
	if(s.value<0){  //如果剩余资源不够,使用block原语使进程从运行态
		block(s.L); //转变成阻塞态,并挂到阻塞队列中;
	}
}
//进程使用资源后,通过signal原语释放;
void signal(semaphore s){
	s.value++;
	if(s.value>=0){ //释放资源后,如果还有别的进程在等待该资源;
		weakup(s.L);//则使用weakup原语唤醒等待队列后的一个进程;
	}               //阻塞态转变成运行态;
}
  • 存在的问题:仅仅限于多个并发进程共享一个临界资源

6.2.4 记录型信号量实现进程同步步骤

  • 首先需要分析什么地方需要实现同步关系,即必须保证一前一后执行的操作;接着,设置同步信号量semaphore s = 0 ;;在前操作之后执行V(S);在后操作之前执行P(S)。
  • 代码
p1(){                      p2(){
	代码1;       			   P(value);  
	代码2;                      代码4;   
	V(value);                  代码5;
	代码3;                      代码6;
}                          }
  • 如果先执行V操作,则value++value=1;,之后执行P操作,由于value=1;,表示有可用资源,会执行value--,s的值变为0,p1进程继续往下执行。
  • 如果先执行P操作,由于value=0; value--;value=-1;,表示此时没有可用资源,因此P操作执行block操作,主动请求阻塞。之后执行代码1和代码2,继而执行V操作,value++;使得value变为0,说明此时有进程在阻塞队列中,因此会执行wakeup原语,唤醒p2进程,这样p2就可以继续执行代码4。

6.2.5 记录型信号量实现进程互斥步骤

  • 分析:把进程的关键活动设置为临界区;设置互斥信号量mutex,初始为1;在临界区前执行P(mutex);在临界区后执行V(mutex)。
  • 代码
p1(){                      p2(){
	....       			    ....    
	p(mutex);               p(mutex);    
	临界区;                  临界区;
	V(mutex);               V(mutex); 
}                          }

6.3 管程

6.3.1 管程定义和引入目的

  • 引入目的:解决信号量机制编程麻烦的问题。
  • 定义:代表共享资源的数据结构以及实施操作资源管理程序

6.3.2 管程的特征

  • 信息封装:管程中的数据结构以及调用函数的具体实现外部不可见
  • 抽象数据类型:管程中不仅有数据,而且有对数据的操作
  • 模块化:即管程是一个程序的基本单位,可以独立编译。

6.3.3 管程解决生产者和消费者问题

condition full=0,empty=n; //条件变量来实现同步;
int count=0; //缓冲区的产品数;
void insert(Item item){
	if(count==N) 
		wait(full);
	count++;
	insert_item(item);
	if(count==1) 
		signal(empty);
}
Item remove(){
	if(count==0) 
		wait(empty);
	count--;
	if(count==N-1)
		signal(full);
	return remove_item();
}

//生产者进程;
producer(){
	while(1){
		item=生产一个产品;
		producerCosumer.insert(item);
	}
}
//消费者进程;
consumer(){
	while(){
		item=producerCosumer.remove(item);
	}
}

6.4 文件锁(读写锁)

  • linux下可以使用flock()函数对文件进行加锁解锁等操作。
  • 定义函数flock函数int flock(int fd,int operation);
  • 函数说明flock()会依参数operation所指定的方式对参数fd所指的文件做各种锁定或解除锁定的动作。此函数只能锁定整个文件,无法锁定文件的某一区域。
  • 参数 operation有下列四种情况:

LOCK_SH(共享锁、读锁):建立共享锁定,多个进程可同时对同一个文件作共享锁定。如果是读取不需要等待,但如果是写入需要等待读取完成。
LOCK_EX(排它锁、写锁):建立互斥锁定,一个文件同时只有一个互斥锁定,。无论写入/读取都需要等待。
LOCK_UN:解除文件锁定状态。无论使用共享/读占锁,使用完后需要解锁。
LOCK_NB:当被锁定时,不阻塞,而是提示锁定。通常与LOCK_SH或LOCK_EX做OR()组合。

  • 返回值:返回0表示成功,若有错误则返回-1,错误代码存于errno。
  • 注意:单一文件无法同时建立共享锁和互斥锁,而当使用dup()或fork()时文件描述词不会继承此种锁定。
  • flock锁的释放非常具有特色,即可调用LOCK_UN参数来释放文件锁,也可以通过关闭fd的方式来释放文件锁(flock的第一个参数是fd),意味着flock会随着进程的关闭而被自动释放掉。
  • 总结:一个进程加LOCK_SH,其他进程也可以加LOCK_SH,但不能加LOCK_EX锁。一个进程加LOCK_EX,其他进程不能对该文件加任何锁。

6.5 无锁CAS

  • 加锁的方法虽然可以保证数据的一致性,但是加锁会引起性能的下降,多个进程竞争同一个锁。因此可以使用原子指令,有一个重要的方法叫做CAS(compare and swap)。CAS是一组原语指令,用来实现多进/线程下的变量同步。x86的指令CMPXCHG实现了CAS,前置LOCK可以达到原子性操作。CAS原语有三个参数,分别为内存地址、期望值、新值。如果内存地址的值==期望值,表示该值未修改,此时可以修改成新值。否则表示修改失败,返回false,由用户决定后续操作。
bool CAS(T* addr, T expected, T newValue) { 
     if(*addr == expected) { 
           *addr = newValue; 
           return true; 
     }
     return false; 
 }
  • 如何使用CAS实现无锁呢?例如有多个进程访问共享内存中的某个变量,从内存中读取该变量的值为expected,你想要更新该变量的值为newValue,可以调用CAS函数,更新共享内存中的值。因为这是一组原子操作,所以在更新的过程中不会有其他进程/线程访问到这个变量。

6.6 校验方式(CRC32校验)

  • 可以用CRC32校验的方式,把变量a的CRC32值记录下来,需要存入另外的变量b
  • 在写入变量a的时候,更新完变量a后,再计算出a的crc32值,同时更新变量b。
  • 在读取变量a的时候,把读出的值a的CRC32值和另外的变量b进行比较,如果不相同,就说明变量a正在更新中,从而实现对该变量的无锁互斥访问。这种方式虽然效率比不上CAS,但相对CAS来说简单可控

7.Linux环境下进程通信

7.1 管道

  • 无名管道(内存文件):管道是一种半双工的通信方式,数据只能单向流动,而且只能在父子关系的进程之间使用
  • 有名管道(FIFO文件,借助文件系统):有名管道也是半双工的通信方式,但是允许在没有父子关系的进程之间使用,管道是先进先出的通信方式。

7.2 信号

7.2.1 常见信号

  • 用于通知接收进程某个事件已经发生,比如按下ctrl + C就是信号。

7.2.2 背景

7.2.2.1 如何让程序在后台运行
  • 正常情况下,如果要运行程序,在命令提示行下输入程序名后回车程序被执行,然后等待程序运行完成,或者使用Ctrl+c中止。在实际开发中,我们需要让程序在后台运行,没有界面,没有用户输入数据。如果想让程序在后台运行,有两种方法:

1.加“&”符号:如果想让程序在后台运行,执行程序的时候,命令的最后面加“&”符号。例如:./book250 &程序就在后台运行了。但是在后台运行的程序,用Ctrl+c无法中断,并且就算终端退出但是程序仍在后台运行。如果终端退出了,后台运行的程序将由系统托管
2.采用fork:主程序执行fork,生成一个子进程,然后父进程退出,留下子进程继续运行,子进程将由系统托管。例如:在main函数后增加以下代码:if (fork()>0) return 0;

7.2.2.2 如何让中止后台运行中程序
  • 直接杀死进程

程序在后台运行,离开了终端控制,用Ctrl+c上无法中止,那怎么让它停下来呢?第一种方法是使用笨方法,直接杀死进程。有两个方法:1)killall 进程名 例如:killall book250; 2)先用ps -ef | grep 进程名找到进程id,然后再kill 进程id

  • 使用信号

7.2.3 信号基本概念

  • 软中断信号(signal,又简称为信号)用来通知进程发生了事件,进程之间可以通过调用kill库函数发送软中断信号。Linux内核也可能给进程发送信号,通知进程发生了某个事(例如内存越界)。注意,信号只是用来通知某进程发生了什么事件,无法给进程传递任何数据,进程对信号的处理方法有三种:

1)第一种方法是,忽略某个信号,对该信号不做任何处理
2)第二种是设置中断的处理函数,收到信号后,由该函数来处理。
3)第三种方法是,对该信号的处理采用系统的默认操作,大部分的信号的默认操作是终止进程

  • Linux信号这篇文章有更多关于信号类型的介绍。

7.2.4 signal库函数

  • signal库函数可以设置程序对信号的处理方式,函数声明:sighandler_t signal(int signum, sighandler_t handler);。其中参数signum表示信号的编号。参数handler表示信号的处理方式,有三种情况:
  1. SIG_IGN:忽略参数signum所指的信号。
  2. 一个自定义的处理信号函数,信号的编号为这个自定义函数的参数。
  3. SIG_DFL:恢复参数signum所指信号的处理方法为默认值,实际开发少用,意义不大。

7.2.5 信号有什么用

  • 服务程序运行在后台,如果想让中止它,强行杀掉不是个好办法,因为程序被杀的时候突然死亡,没有释放资源,会影响系统的稳定,用Ctrl+c中止与杀程序是相同的效果。
  • 如果能向后台程序发送一个信号,后台程序收到这个信号后调用函数,在函数中编写释放资源的代码,程序就可以有计划的退出,安全而体面。

7.2.6 信号处理函数被中断和阻塞

  • 当一个信号到达后,调用处理函数。如果这时候有其它的信号发生,会中断之前的处理函数,等待新的信号处理函数执行完成后再继续执行之前的处理函数。但是如果是同一个信号会发生排队阻塞
  • 如果不希望在接到新号时中断当前处理函数,也不希望忽略该信号,而是延迟一段时间再处理这个函数,这种情况情况可以通过阻塞信号实现。
  • 信号的阻塞和忽略是不同概念,被阻塞的信号不会影响进程的行为,信号只是暂时被阻止传递,而信号的忽略,信号会被传递出去但是进程将信号丢弃。

7.2.7 发送信号

  • Linux操作系统提供kill命令向程序发送信号,C语言也提供kill库函数,用于在程序中向其它进程或者线程发送信号。函数声明:int kill(int pid, int sig);,kill函数将参数sig指定的信号给参数pid指定的进程
  • 参数pid有几种情况:

1)pid>0 将信号传给进程号为pid 的进程
2)pid=0 将信号传给和目前进程相同进程组的所有进程,常用于父进程给子进程发送信号。注意,发送信号者进程也会收到自己发出的信号。
3)pid=-1 将信号广播传送给系统内所有的进程,例如系统关机时,会向所有的登录窗口广播关机信息。

  • sig:准备发送的信号代码,假如其值为零则没有任何信号送出,但是系统会执行错误检查,通常会利用sig值为零来检验某个进程是否仍在运行。返回值说明: 成功执行时,返回0;失败返回-1,errno被设为以下的某个值:

EINVAL:指定的信号码无效(参数 sig 不合法)。
EPERM:权限不够无法传送信号给指定进程。
ESRCH:参数 pid 所指定的进程或进程组不存在。

7.3 消息队列

  • 消息队列(message):进程可以向队列中添加消息,其它的进程则可以读取队列中的消息。消息队列是消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服信号量机制传递数据少管道只能承载无格式字节流以及缓冲区大小受限等缺点。

7.4 共享内存

7.4.1 共享内存定义

  • 共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的进程间通信方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与信号量配合使用来实现进程间的同步和通信。

7.4.2 共享内存过程

7.4.2.1 创建/获取共享内存
  • shmget函数:shmget函数用来获取或创建共享内存,它的声明为:int shmget(key_t key, size_t size, int shmflg);

参数key是共享内存的键值,是一个整数,typedef unsigned int key_t,是共享内存在系统中的编号,不同共享内存的编号不能相同,这一点由程序员保证。key用十六进制表示比较好。
参数size是待创建的共享内存的大小,以字节为单位。
参数shmflg是共享内存的访问权限,与文件的权限一样,0666|IPC_CREAT表示全部用户对它可读写,如果共享内存不存在,就创建一个共享内存。

7.4.2.2 把共享内存连接到当前进程的地址空间
  • shmat函数:把共享内存连接到当前进程的地址空间。它的声明如下:void *shmat(int shm_id, const void *shm_addr, int shmflg);

参数shm_id是由shmget函数返回的共享内存标识。
参数shm_addr指定共享内存连接到当前进程中的地址位置,通常为空,表示让系统来选择共享内存的地址。
参数shm_flg是一组标志位,通常为0。
调用成功时返回一个指向共享内存第一个字节的指针,如果调用失败返回-1.

7.4.2.3 通过指针访问共享内存
  • 访问共享内存可以使用指针。
7.4.2.4 将共享内存从当前进程分离
  • shmdt函数:该函数用于将共享内存从当前进程中分离,相当于shmat函数的反操作。它的声明如下:int shmdt(const void *shmaddr);

参数shmaddr是shmat函数返回的地址。
调用成功时返回0,失败时返回-1.

7.4.2.5 删除共享内存
  • shmctl函数:删除共享内存,它的声明如下:int shmctl(int shm_id, int command, struct shmid_ds *buf);

参数shm_id是shmget函数返回的共享内存标识符。
参数command填IPC_RMID。
参数buf填0。

  • 解释一下,shmctl是控制共享内存的函数,其功能不只是删除共享内容。
  • 注意,用root创建的共享内存,不管创建的权限是什么,普通用户无法删除。
7.4.2.6 完整案例
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/shm.h> 
int main()
{
     int shmid; // 共享内存标识符
 
    // 创建共享内存,键值为0x5005,共1024字节。
  if ( (shmid = shmget((key_t)0x5005, 1024, 0640|IPC_CREAT)) == -1)
  { printf("shmat(0x5005) failed\n"); return -1; }
   
  char *ptext=0;   // 用于指向共享内存的指针
 
  // 将共享内存连接到当前进程的地址空间,由ptext指针指向它
  ptext = (char *)shmat(shmid, 0, 0);
 
  // 操作本程序的ptext指针,就是操作共享内存
  printf("写入前:%s\n",ptext);
  sprintf(ptext,"本程序的进程号是:%d",getpid());
  printf("写入后:%s\n",ptext);
 
  // 把共享内存从当前进程中分离
  shmdt(ptext);
   
  // 删除共享内存
  // if (shmctl(shmid, IPC_RMID, 0) == -1)
  // { printf("shmctl(0x5005) failed\n"); return -1; }
}

7.4.3 共享内存操作命令

  • ipcs -m可以查看系统的共享内存,内容有键值key,共享内存编号shmid,创建者owner,权限perms,大小bytes。
  • ipcrm -m共享内存编号,可以手工删除共享内存。

7.5 信号量

7.5.1 信号量定义

  • 信号量本质上是一个计数器,用于协调多个进程(包括但不限于父子进程)对共享数据对象的读/写。它不以传送数据为目的,主要是用来保护共享资源(共享内存、消息队列、socket连接池、数据库连接池等),保证共享资源在一个时刻只有一个进程独享。也就是说它常作为一种锁机制,实现进程、线程的对临界区的同步及互斥访问。
  • 信号量是一个特殊的变量,只允许进程对它进行等待信号和发送信号操作。最简单的信号量是取值0和1的二元信号量,这是信号量最常见的形式。

7.5.2 二元信号量

7.5.2.1 semget函数
  • semget函数用来获取或创建信号量,它的原型如下:int semget(key_t key, int nsems, int semflg);

1)参数key是信号量的键值,typedef unsigned int key_t,是信号量在系统中的编号,不同信号量的编号不能相同,这一点由程序员保证。key用十六进制表示比较好。
2)参数nsems是创建信号量集中信号量的个数,该参数只在创建信号量集时有效,这里固定填1。
3)参数sem_flags是一组标志,如果希望信号量不存在时创建一个新的信号量,可以和值IPC_CREAT做按位或操作。如果没有设置IPC_CREAT标志并且信号量不存在,就会返错误(errno的值为2,No such file or directory)。
4)如果semget函数成功,返回信号量集的标识;失败返回-1,错误原因存于error中。

  • 示例:

1)获取键值为0x5000的信号量,如果该信号量不存在,就创建它,代码如下:int semid=semget(0x5000,1,0640|IPC_CREAT);
2)获取键值为0x5000的信号量,如果该信号量不存在,返回-1,errno的值被设置为2,代码如下:int semid= semget(0x5000,1,0640);

7.5.2.2 semctl函数
  • 该函数用来控制信号量(常用于设置信号量的初始值和销毁信号量),它的原型如下:int semctl(int semid, int sem_num, int command, ...);

1)参数semid是由semget函数返回的信号量标识。
2)参数sem_num是信号量集数组上的下标,表示某一个信号量,填0。
3)参数cmd是对信号量操作的命令种类,常用的有以下两个:
4)IPC_RMID:销毁信号量,不需要第四个参数;
5)SETVAL:初始化信号量的值(信号量成功创建后,需要设置初始值),这个值由第四个参数决定。第四参数是一个自定义的共同体,如下 用于信号灯操作的共同体:union semun {int val; struct semid_ds *buf; unsigned short *arry; };
6)如果semctl函数调用失败返回-1;如果成功,返回值比较复杂,暂时不关心它。

  • 示例:

1)销毁信号量。 semctl(semid,0,IPC_RMID);
2)初始化信号量的值为1,信号量可用。
union semun sem_union;
sem_union.val = 1;
semctl(semid,0,SETVAL,sem_union);

7.5.2.3 semop函数
  • 该函数有两个功能:1)等待信号量的值变为1,如果等待成功,立即把信号量的值置为0,这个过程也称之为等待锁;2)把信号量的值置为1,这个过程也称之为释放锁,函数原型为:int semop(int semid, struct sembuf *sops, unsigned nsops);

1)参数semid是由semget函数返回的信号量标识。
2)参数nsops是操作信号量的个数,即sops结构变量的个数,设置它的为1(只对一个信号量的操作)。
3)参数sops是一个结构体,如下:
struct sembuf
{
short sem_num; // 信号量集的个数,单个信号量设置为0。
short sem_op; // 信号量在本次操作中需要改变的数据:-1-等待操作;1-发送操作。
short sem_flg; // 把此标志设置为SEM_UNDO,操作系统将跟踪这个信号量。
// 如果当前进程退出时没有释放信号量,操作系统将释放信号量,避免资源被死锁。
};

  • 示例:

1)等待信号量的值变为1,如果等待成功,立即把信号量的值置为0;
struct sembuf sem_b;
sem_b.sem_num = 0;
sem_b.sem_op = -1;
sem_b.sem_flg = SEM_UNDO;
semop(sem_id, &sem_b, 1);
2)把信号量的值置为1。
struct sembuf sem_b;
sem_b.sem_num = 0;
sem_b.sem_op = 1;
sem_b.sem_flg = SEM_UNDO;
semop(sem_id, &sem_b, 1);

7.5.2.4 完整案例
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/ipc.h>
#include <sys/sem.h>
class CSEM{
private:
     union semun{  // 用于信号灯操作的共同体。
     int val;
     struct semid_ds *buf;
     unsigned short *arry;};
     int  sem_id;  // 信号灯描述符。
public:
     bool init(key_t key); 
  //如果信号灯已存在,获取信号灯;如果信号灯不存在,则创建信号灯并初始化。
     bool wait();          // 等待信号灯挂出。
     bool post();          // 挂出信号灯。
     bool destroy();       // 销毁信号灯。
};

int main(int argc, char *argv[])
{
   CSEM sem;
 
   //初始信号灯。
   if (sem.init(0x5000)==false){ 
   		printf("sem.init failed.\n"); 
   		return -1; 
   }
   printf("sem.init ok\n");
  
   //等待信信号挂出,等待成功后,将持有锁。
   if (sem.wait()==false) { 
        printf("sem.wait failed.\n"); return -1; 
   }
   printf("sem.wait ok\n");
 
   //在sleep的过程中,运行其它的book259程序将等待锁。
   sleep(50);  
  
   // 挂出信号灯,释放锁。
   if (sem.post()==false) { 
   		printf("sem.post failed.\n");
   		return -1;
   }
   printf("sem.post ok\n");
  
   //销毁信号灯。
   /*if (sem.destroy()==false) { 
   		printf("sem.destroy failed.\n"); 
        return -1; 
   }
   printf("sem.destroy ok\n");*/
}
 
bool CSEM::init(key_t key){
  // 获取信号灯。
   if((sem_id=semget(key,1,0640)) == -1){
    // 如果信号灯不存在,创建它。
    if (errno==2){
	      if ((sem_id=semget(key,1,0640|IPC_CREAT)) == -1) { 
	           perror("init 1 semget()");
	           return false;
	      }
	 
	      // 信号灯创建成功后,还需要把它初始化成可用的状态。
	      union semun sem_union;
	      sem_union.val = 1;
	      if (semctl(sem_id,0,SETVAL,sem_union) <  0) { 
	      		perror("init semctl()"); 
	      		return false;
	     }
    }else
    { 
	    perror("init 2 semget()"); 
	    return false; 
    }
 }
   return true;
}
 
bool CSEM::destroy(){
  if (semctl(sem_id,0,IPC_RMID) == -1) { 
  		perror("destroy semctl()"); 
  		return false;
  }
  return true;
}
 
bool CSEM::wait(){
  struct sembuf sem_b;
  sem_b.sem_num = 0;
  sem_b.sem_op = -1;
  sem_b.sem_flg = SEM_UNDO;
  if (semop(sem_id, &sem_b, 1) == -1) { 
	  perror("wait semop()"); 
	  return false;
   }
  return true;
}
 
bool CSEM::post(){
	struct sembuf sem_b;
	sem_b.sem_num = 0;
	sem_b.sem_op = 1;  
	sem_b.sem_flg = SEM_UNDO;
    if (semop(sem_id, &sem_b, 1) == -1) { 
    	perror("post semop()"); 
    	return false; 
    }
    return true;
}

7.5.3 其它的操作命令

  • ipcs -s可以查看系统的信号量,内容有键值key,信号量编号semid,创建者owner,权限perms,信号量数nsems。
  • ipcrm sem信号量编号,可以手工删除信号量。

7.6 套接字

  • 适用于不同机器间进程通信,在本地也可作为两个进程通信的方式。

7.7 总结

  • Linux几乎支持全部UNIX进程间通信方法,包括管道(有名管道和无名管道)、消息队列、共享内存、信号量和套接字。其中前四个属于同一台机器下进程间的通信,套接字则是用于网络通信

8.进程其它相关问题

8.1 被换出的进程存放的位置

  • 具有对换功能的操作系统中,通常把磁盘空间分为文件区和对换区两部分。
  • 文件区主要用于存放文件,主要追求存储空间的利用率,因此对文件区空间的管理采用离散分配方式
  • 对换区空间只占磁盘空间的小部分,被换出的进程数据就存放在对换区。由于对换的速度直接影响到系统的整体速度,因此对换区空间的管理主要追求换入换出速度,因此通常对换区采用连续分配方式
  • 总之,对换区的I/O速度比文件区的更快

8.2 原子操作的是如何实现的

  • 处理器使用基于对总线加锁或者缓存加锁的方式来实现多处理器之间的原子操作。首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。Pentium 6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度、跨多个缓存行和跨页表的访问。但是,处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。
  • 使用总线锁保证原子性:如果多个处理器同时对共享变量进行读改写操作(i++就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致。举个例子,如果i=1,我们进行两次i++操作,我们期望的结果是3,但是有可能结果是2,如下所示:
CPU1            CPU2
i=1             i=1
i+1             i+1
i=2             i=2
  • 原因可能是多个处理器同时从各自的缓存中读取变量i,分别进行加1操作,然后分别写入系统内存中。那么,想要保证读改写共享变量的操作是原子的,就必须保证CPU1读改写共享变量的时候,CPU2 不能操作该共享变量内存地址的缓存。处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存
  • 使用缓存锁保证原子性:在同一时刻,我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。例如:频繁使用的内存会缓存在处理器的L1、L2和L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁,在Pentium 6和目前的处理器中可以使用缓存锁定的方式来实现复杂的原子性。所谓缓存锁定是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性。因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效,在如上图所示的例子中,当CPU1修改缓存行中的i时使用了缓存锁定,那么CPU2就不能使用缓存i的缓存行。
  • 但是有两种情况下处理器不会使用缓存锁定。 第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定。 第二种情况是:有些处理器不支持缓存锁定。对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。

9.进程基础相关面试题

1.linux终端关闭时为什么会导致在其上启动的进程退出?

  • 终端在退出时会发送SIGHUP对应的bash进程,bash进程收到这个信号后首先将它发给session下面的进程。如果程序没有对SIGHUP信号做特殊处理,那么进程就会随着终端关闭而退出。

2fork和vfork的区别

  • fork子进程拷贝父进程的数据段,代码段;vfork子进程与父进程共享数据段。
  • fork 父子进程的执行次序不确定;vfork 保证子进程先运行,在调用exec 或exit 之前与父进程数据是共享的,在它调用exec或exit 之后父进程才可能被调度运行。
  • vfork保证子进程先运行,在她调用exec 或exit 之后父进程才可能被调度运行。如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁。

3.发生内存泄漏和关闭进程的关系

  • 进程结束后,所有内存都将被释放,包括堆上的内存泄露的内存。原因是,当进程结束时,GDT、LDT和页目录都被操作系统更改,逻辑内存全部消失,可能物理内存的内容还在但是逻辑内存已经从LDT和GDT删除,页目录表全部销毁,所以内存会被全部收回。
  • 不管用户程序怎么malloc,在进程结束的时候,其虚拟地址空间就会被直接销毁,操作系统只需要在进程结束的时候不管用户程序怎么malloc,在进程结束的时候,其虚拟地址空间就会被直接销毁,操作系统只需要在进程结束的时候
    让内存管理模块把分页文件中与此进程相关的记录全部删除,标记为可用空间,就可以使所有申请的内存都一次性地回收,根本没有什么麻烦。简单说,malloc分配都是假的,malloc请求系统都知道,程序退出时系统会回收malloc 的所有资源。
  • 有些内存系统是回收不了的。例如运行于内核级的驱动造成的内存错误等, 这些是系统所管不了的。这种错误,重启程序是没有效果的。必须重启电脑才能解决。

阻塞队列的实现和优化

10.进程同步和通信相关面试题

11.其它相关面试题

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值