1. 进程概念
1.1 进程基本概念
我们知道OS可能会在任何时候暂停或是继续一个程序的运行。
程序在并发环境中可能存在以下的问题:
- 运行过程不确定
- 结果不可再现(程序运行被干扰)
因此OS引入了进程的概念。那什么是进程呢?进程其实就是程序在某个数据集合上的一次运行活动。简单点说,进程就是运行中的程序,它是一个动态的概念
进程的特征
- 动态性:进程是程序的一次执行过程
- 并发性:进程同其他进程一起向前推进
- 异步性:进程按照各自的速度向前推进
- 独立性:进程是系统分配资源和调度CPU的单位(进程是CPU分配资源的最小单位)
进程 | 程序 |
---|---|
动态:进程是程序的一次执行过程 | 静态 |
暂存:在内存驻留 | 长存:在硬盘存储 |
一个程序可能有多个进程 |
1.2 进程状态
- 运行状态:进程已经占有CPU,在CPU运行
- 就绪状态:具备运行条件,但是没有CPU,暂时无法运行
- 阻塞状态:因为等待某项服务完成或信号不能运行的状态,如系统调用、I/O操作等
1.2.1 进程的状态变迁
进程的状态可以根据一定的条件相互转化
1.3 进程控制块PCB
进程控制块PCB是描述进程状态、资源和与相关进程关系的数据结构。PCB在进程创建时创建,在进程撤销后PCB也同时撤销。
我们可以这样认为:进程 = 程序 + PCB
1.3.1 PCB的基本成员
- name(ID):进程名称
- status:状态
- next:指向下一个PCB的指针
- priority:优先级
- cpu_status:现场保留区(堆栈)
- comm_Info:进程通信
- process_family:家族
- own_resource:资源
Linux的PCB是有task_struct描述的。
Linux的进程标识:
- PID:进程ID
- PPID:父进程ID
- PGID:进程组ID
Linux进程的用户标识:
- UID:用户ID
- GID:用户组ID
1.3.2 进程的切换
首先说明一下进程的上下文Context,Context指的是进程的运行环境即CPU环境。
进程的切换过程:
- 换入进程的上下文进入CPU(从栈上来)
- 换出进程的上下文离开CPU(从栈上去)
1.3.3 进程创建
创建一个具有指定标识(ID)的进程,其参数包括:
- 进程标识、优先级、进程起始地址、CPU初始状态、资源清单等
进程创建过程:
- 创建一个空白PCB
- 获得并赋予进程标识符ID
- 为进程分配内存空间
- 初始化PCB(默认值)
- 插入相应的进程队列(新进程插入就绪队列)
1.3.4 进程撤销
进程撤销的时机
- 正常结束
- 异常结束
- 外界干预
需要的参数有
- 被撤销进程名(ID)
进程撤销过程:
- 在PCB队列中检索出该PCB
- 获取该进程的状态
- 若该进程处于运行态,立即终止该进程(
递归检查是否有子进程,先撤销子进程
) - 释放进程占用的资源
- 将进程从PCB队列中移除
1.3.5 进程阻塞
停止进程的执行,变为阻塞。
阻塞的时机:
- 请求系统服务(由于某种原因,OS不能立即满足进程的要求)
- 启动某种操作(进程启动某种操作,阻塞等待该操作完成)
- 新数据尚未到达(A进程要获取B进程的中间结果,A进程等待)
- 无新工作可做(进程完成任务,自我阻塞,等待新任务)
阻塞的实现过程:
- 停止进程进程
- 将PCB“运行态“改为“阻塞态”
插入相应原因的阻塞队列
- 转调度程序
1.3.6 进程唤醒
唤醒处于阻塞队列当中的某个进程。
唤醒时机:
- 系统服务不满足到满足
- I/O完成
- 新数据到达
- 进程提出新请求(服务)
1.3.7 进程控制原语
原语:
- 由若干指令构成的具有特定功能的函数
- 具有原子性,其操作不可分割
进程控制原语包括:
- 创建原语
- 撤销原语
- 阻塞原语
- 唤醒原语
2. 进程控制
2.1 Windows进程控制
函数 | 描述 |
---|---|
CreateProcess() | 创建进程 |
ExitProcess(UINT uEixtCode) | 结束进程 |
TerminateProcess(hProcess, uExitCode) | 结束进程 |
2.2 Linux进程控制
pid_t pid = fork()
:创建进程
- 父进程是当前进程的子进程
- 父进程:fork()的调用者;子进程:新建的进程
- 子进程是父进程的复制
- 父进程和子进程
并发运行
fork返回值pid有
- 子进程中 pid = 0
- 父进程中 pid > 0
- 出错 pid = -1
分析下面的代码:
int main(void){
pid_t pid;
pid = fork()
//子进程将会执行fork()一下的所有代码
if(pid == 0) printf("child process");
else if(pid > 0) printf("parent process");
else printf("error");
}
上述代码将会输出
child process
parent process
或者
parent process
child process
do_fock()函数的实现
//fork()函数详解
//代码来自github:https://github.com/torvalds/linux/blob/master/kernel/fork.c
/*
* It copies the process, and if successful kick-starts
* it and waits for it to finish using the VM if required.
*
* args->exit_signal is expected to be checked for sanity by the caller.
*/
long _do_fork(struct kernel_clone_args *args)
{
u64 clone_flags = args->flags;
struct completion vfork;
struct pid *pid;
struct task_struct *p;
int trace = 0;
long nr;
/*
* Determine whether and which event to report to ptracer. When
* called from kernel_thread or CLONE_UNTRACED is explicitly
* requested, no event is reported; otherwise, report if the event
* for the type of forking is enabled.
*/
if (!(clone_flags & CLONE_UNTRACED)) {
if (clone_flags & CLONE_VFORK)
trace = PTRACE_EVENT_VFORK;
else if (args->exit_signal != SIGCHLD)
trace = PTRACE_EVENT_CLONE;
else
trace = PTRACE_EVENT_FORK;
if (likely(!ptrace_event_enabled(current, trace)))
trace = 0;
}
//把当前进程task_struct结构中所有内存拷贝到新进程中
p = copy_process(NULL, trace, NUMA_NO_NODE, args);
add_latent_entropy();
//判断进程拷贝是否成功,如果失败则返回错误
if (IS_ERR(p))
return PTR_ERR(p);
/*
* 在唤醒新线程前执行此操作 - 如果线程迅停止那线程指针可能会失效
*/
trace_sched_process_fork(current, p);
//获取新(子)进程的ID
pid = get_task_pid(p, PIDTYPE_PID);
nr = pid_vnr(pid);
if (clone_flags & CLONE_PARENT_SETTID)
put_user(nr, args->parent_tid);
if (clone_flags & CLONE_VFORK) {
p->vfork_done = &vfork;
init_completion(&vfork);
get_task_struct(p);
}
wake_up_new_task(p); //唤醒进程,将其挂入可执行队列等待被调度
/* forking complete and child started to run, tell ptracer */
if (unlikely(trace))
ptrace_event_pid(trace, pid);
if (clone_flags & CLONE_VFORK) {
if (!wait_for_vfork_done(p, &vfork))
ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
}
put_pid(pid);
return nr;
}
系统调用 | 描述 |
---|---|
fork | fork创造的子进程是父进程的完整副本,复制了父亲进程的资源,包括内存的内容task_struct内容 |
vfork | vfork创建的子进程与父进程共享数据段,而且由vfork()创建的子进程将先于父进程运行 |
clone | Linux上创建线程一般使用的是pthread库 实际上linux也给我们提供了创建线程的系统调用,就是clone |
系统调用服务例程sys_clone, sys_fork, sys_vfork三者最终都是调用do_fork函数完成.
三者的区别和关联请参考 https://blog.youkuaiyun.com/gatieme/article/details/51417488
3. 线程
python实现代码:
import queue,random,threading
from time import sleep
def drawRect():
print('开始画矩形')
for i in range(10):
print('画矩形中')
print('完成矩形')
def drawCircle():
print('开始画圆')
for i in range(10):
print('画圆中')
print('完成画圆')
if __name__ == '__main__':
t1 = threading.Thread(target=drawRect)
t2 = threading.Thread(target=drawCircle)
t1.start()
t2.start()
t1.join()
t2.join()
单线程和多线程程序:
- 单线程程序:整个进程只有一个线程。windows程序确实只有一个主线程
- 多线程程序:整个进程至少有两个线程。主线程和至少一个用户线程
线程的应用场景
- 程序的功能需要并发运行(如同时画圆和画方)
- 提高窗口程序的交互性
- 多核CPU的应用,重复发挥性能
windows创建线程:CreateThread()
Linux创建线程:pthread_create()
使用线程的问题
- 程序难以调试
- 并发过程难以控制
- 线程安全问题
4. 临界区和锁
4.1 临界资源和临界区
4.1.1 什么是临界区和临界资源
- 临界资源:一次只允许一个进程独占访问的资源,例如:上图中的共享变量i
- 临界区:进程中访问临界资源的程序段。并发进程不能同时进入临界区。
4.1.2 设计临界区访问机制的四个原则
- 忙则等待:临界区忙时,其他进程必须在临界区外等待
- 空闲让进:当无进程在临界区,则任何有权进程可进入临界区
- 有限等待:进程进入临界区的请求应在有限时间内得到满足,这就涉及到临界区的大小问题。
- 让权等待:等待进程放弃CPU,让其他进程有机会得到CPU
4.2 锁机制
4.2.1 锁机制原理
设置一个标志S, 则有 S == 1 ? "临界资源可用" : "临界资源不可用"
上锁操作:
- 检查S是否可用
- 若为不可用状态,则
进程在临界区外等待
- 若可用,则访问临界区资源,且将S修改位
不可用状态
开锁操作:
- 退出临界区将S改为
可用状态
LOCK(S){
TEST: if(S == 0)
goto TEST; //测试锁状态
else
S = 0
}
UNLOCK(S){
S = 1;//开锁
}
4.2.2 用锁机制访问临界区
步骤:
- 初始化锁状态S = 1(可用)
- 进入临界区前执行上锁LOCK操作
- 离开临界区后执行开锁UNLOCK操作
5. 同步和P-V操作
5.1 同步和互斥概念
5.1.1 进程同步
若干个进程为了完成一个共同的任务,需要相互协调运行步伐。一个进程开始某个操作之前必须要求另一个进程已经完成某个操作,否则前面的进程只能等待。
进程同步关系的例子:司机和售票员
- 司机:起步,行驶,停车
- 售票员:关门,售票,开门
同步关系 - 司机起步前售票员要先关门,否则等待
- 售票员开门前司机要先停车,否则等待
5.1.2 进程互斥
多个进程由于共享了独占性支援,必须协调各进程对资源的存取顺序,确保没有任何两个或以上的进程同时进行存取操作。互斥和资源共享有关。这个存取操作区域为临界区。
5.2 P-V操作
5.2.1 信号灯概念
信号灯是一种卓有成效的进程同步机制。由Dijkstra于1965年提出。进程在运行过程中受信号灯控制,并能改变信号灯状态。
信号灯数据结构
- 信号灯变量定义为一个二元矢量(S, q)
- S:整数,初值为负。S又称信号量
- q:PCB队列,初值为空集
struct SEMPHORE
{
int S;
pointer_PCB q;
}
信号灯的操作有两种:
- P操作(通过):P(S, q)
- V操作(释放):V(S, q)
5.2.2 信号灯的操作
P操作 | V操作 | |
---|---|---|
1 | S-- | S++ |
2 | 若S >= 0 ,进程继续 | 若S > 0 ,进程继续 |
3 | 若S < 0 ,进程阻塞并加入到队列q中,并转调度函数 | 若S <= 0 ,该进程继续,同时从q中唤醒一个进程 |
注 | P操作可能使进程在调用处阻塞。 | 可能唤醒阻塞的进程 |
5.3 P-V操作解决互斥问题
解决互斥实质是实现对临界区的互斥访问,即允许最多一个进程处于临界区
应用过程
- 设定合理的S初值
- 进入临界区前执行P操作
- 离开临界区之后执行V操作
分析Pa,b,c三者互斥过程,代码如下:
main(){
int mutex = 1;
cobegin //并发
Pa();
Pb();
Pc();
coend();//并发结束
}
Pa(){
P(mutex);
CS;//访问临界区
V(mutex);
}
Pb(){
P(mutex);
CS;//访问临界区
V(mutex);
}
Pc(){
P(mutex);
CS;//访问临界区
V(mutex);
}
分析,
Pa | Pb |
---|---|
S = 1 | S = 1 |
Pa运行 | Pb运行 |
假设Pa进程先执行,则S-- ,所以S = 0 | Pb执行时,S = 0,所以阻塞 |
S >= 0,所以Pa进程继续 | 阻塞 |
Pa进入临界区 | 阻塞 |
S++ ,所以S = 1 | 阻塞 |
S > 0,Pa进程继续,同时唤醒阻塞进程 | Pb被唤醒 |
Pa继续执行 | S-- |
Pa继续执行 | S = 0,所以Pb进程继续执行 |
5.4 P-V操作解决同步问题
基本思路
- 定义有意义的信号量S,设置合适的初值。S能明确的表示运行条件。
- 暂停当前进程:在关键操作执行之前执行 P 操作
- 继续进程:在关键操作之后执行 V 操作
继续以司机和售票员为例:
- 司机:起步,行驶,停车
- 售票员:关门,售票,开门
同步关系
- 司机起步前售票员要先关门,否则等待
- 售票员开门前司机要先停车,否则等待
即:售票员关门 -> 司机起步
-> 司机行驶,售票员售票 -> 司机停车 -> 售票员开门
-> 售票员关门 -> …
import threading
import time
s_door = threading.Semaphore(0) # 门是否关好
s_car = threading.Semaphore(0) # 车是否停稳
def driver():
for i in range(2):
s_door.acquire() # P操作,意味着分配资源。获取关好门的信号
print('司机起步')
print('司机行驶')
time.sleep(1)
print('司机停车')
s_car.release() # V操作,意味着释放资源。发出车停稳的信号
def seller():
for i in range(2):
print('售票员关门')
s_door.release() # V操作,发出关好门的信号
time.sleep(1)
print('售票员售票')
s_car.acquire() # P操作,获取车停稳的信号
print('售票员开门')
if __name__=='__main__':
p1 = threading.Thread(target=driver)
p2 = threading.Thread(target=seller)
p1.start()
p2.start()
p1.join()
p2.join()
总结:
- 关键操作之前 P 操作
- 关键操作之后 V 操作
上例中,司机起步和售票员开门是关键操作,因此在这些操作前 P 操作,之后 V 操作。
- 售票员开门会使S1从 1 -> 0
- 司机起步会使S2从1 -> 0
经典同步问题1:生产者和消费者问题
一群生产者向一群消费者提供产品,共享缓冲区,如下图示。规则是
- 不能向
满
缓存区存
产品 - 不能从
空
缓冲区取
产品 - 每个时刻仅允许
1个生产者
或消费者
存
或取
1个产品
int FULL = 0; // 缓冲区数据个数,初值为0
int EMPTY = 5; // 缓存区空位个数,初值为5
int S3 = 1; // 是否允许用户存或取,互斥使用
Producer i(){
while(TRUE)
{
P(EMPTY);
P(S3);
存产品 //存产品会使得 EMPTY从非0到0
V(S3);
V(FULL);
}
}
Consumer j(){
while(TRUE)
{
P(FULL);
P(S3);
取产品 //取产品会使得 FULL从非0到0
V(S3);
V(EMPTY);
}
}
import queue,random,threading
from time import sleep
mutex = threading.Semaphore(1) # 临界区互斥信号量
s_empty = threading.Semaphore(5) # 缓冲区空闲的信号量
s_full = threading.Semaphore(0) # 缓冲区满了的信号量
def productor(i, q):
while True:
num = random.choice(['华为P30', '小米9', 'ViVo x27', 'iphone XR'])
print('生产者%d生产了产品%s' % (i, num))
s_empty.acquire() # 缓冲区是否有空闲
mutex.acquire() # 获取缓冲区无人访问信号
q.put(num)
sleep(1)
print('生产者%d把产品%s放入了仓库中' % (i, num))
mutex.release()
s_full.release()
def consumer(i, q):
while True:
s_full.acquire()
mutex.acquire()
num = q.get()
sleep(1)
print('消费者%d购买了产品%s' % (i, num))
mutex.release()
s_empty.release()
if __name__ == '__main__':
q = queue.Queue(5) # 创建上限为5的缓冲区
# 创建2个生产者
for i in range(2):
threading.Thread(target=productor, args=(i, q)).start()
# 创建3个消费者
for i in range(3):
threading.Thread(target=consumer, args=(i, q)).start()
经典同步问题2:读者和编者问题
问题描述:有一本书
- 有读者读书;有多个读者
- 有编者编书;有多个编者
要求:
- 允许多个读者同时读书:读者间不互斥
- 不允许多个编者同时编书:编者间互斥
- 不允许读者编者同时操作:编者和读者间互斥
int RNUM = 0; // 读者个数
int r_mutex= 1; // 临界资源RNUM的互斥访问
int e_r_mutex = 1; // 编者和读者互斥,可认为是允许编者或读者访问
Reader i(){
while(TRUE)
{
P(r_mutex);//保证以下操作的原子性
RNUM++;
if(RNUM == 1) P(e_r_mutex )
V(r_mutex );
读书;
P(r_mutex);
RNUM--;
if(RNUM == 0) V(e_mutex)
V(r_mutex);
}
}
Editor j(){
while(TRUE)
{
P(e_r_mutex );
编书
V(e_r_mutex );
}
}
6. Windows同步机制
互斥量(Mutex)
- 保证一个线程或进程可以申请到该对象
- 可以跨进程使用
- 可以有名称
- 互斥量比临界区资源耗费更多资源,速度慢
信号量(Semaphore)
- 允许指定数目的多个进程或线程访问临界区
- 是一种资源计数器,用于限制并发线程的数量
- 初始值设置为N,则代表允许N个进程或线程访问资源
7. Linux同步机制
- 对于普通变量,父子进程各自操作变量副本,互相不影响
- 对文件资源,父子进程共享同一文件和读写指针
8. 进程通信
8.1 管道通信
8.1.1 管道通信机制
管道是进程间的一种通信机制。一个进程A可以通过管道将数据传输给另一个进程B。
程序A写数据 =====管道=====>
程序B读数据
管道通信仅能用于父子或兄弟进程间通信。如果用进行双写通信,则需要建立2个管道。
8.2 Linux信号(Signal)通信
信号的概念:
- 信号是Linux进程间的一种重要通信机制
- 信号是向进程发送一个通知,通知某个事件已发生
- 收到信号的进程可以立即执行指定的操作
- 信号的发出可以是进程,也可以是系统
信号产生的例子:
- 键盘按下 Ctrl + C,产生
SIGINT
信号,杀死一个进程 - 键盘按下 Ctrl + Z,产生
SIGTSTP
信号,暂停一个进程 kill -9
产生SIGKILL
信号,强制结束进程- 程序调用函数产生信号,如kill(), abort()
- 硬件异常或者内存产生相应信号,如内存访问错误
Signal函数原型:
void signal(
int SigNo, // 信号编号
void (* Handle) int // 自定义信号处理函数
)
kill(): 发送信号的函数
/*
* 向目标进程PID发送SigNo信号
*/
void kill(
int PID, // 接受信号的目标进程ID
int SigNo //待发送的信号
)