操作系统笔记4——并发处理和死锁

本节讲操作系统的并发处理,包括原理、互斥、信号量、管程、消息传递、锁问题等

并发的术语

  • 原子操作
    保证指令序列要么作为一个组来执行,要么都不执行;

    • 原语:原(子操作的)语,代表一个原子操作的语句,在实际编程中代表一个执行原子操作的函数或方法或语句块。
  • 临界区
    一段代码,在这段代码中进程将访问共享资源,当一个进程已经在这段代码中运行时,另外一个进程就不能在这段代码中执行;

  • 死锁
    两个或两个以上的进程因其中的每个进程都在等待其他进程做完某些事情而不能继续执行

  • 活锁
    两个或两个以上进程为了响应其他进程中的变化而持续改变自己的状态不做有用的工作

    • 较少出现,本书不多讲
  • 互斥
    当一个进程在临界区访问共享资源时,其他进程不能进入该临界区访问任何共享资源

  • 竞争条件
    多个线程或进程在读写一个共享数据时,结果依赖于它们执行的相对时间;

  • 同步

    所谓同步,就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步

  • 饥饿
    一个可运行的进程被调度程序无限期地忽略,不能被调度执行的情形。

    • 饥饿对操作系统没有影响,故较少被考虑

并发的原理

概念

  • 并发
    单处理器多道程序设计系统中,进程交替执行;
  • 并行
    多处理器系统中,不仅可以交替执行进程,还可以重叠执行进程。
  • 并发问题
    • 并发进程的相对执行速度是不可预测的,取决于其他进程的活动、操作系统处理中断的方式以及操作系统的调度策略。
    • 可能发生各种与时间有关的错误。

并发带来的设计和管理问题

  • 操作系统必须能跟踪不同的进程;
  • 操作系统必须为每个活跃进程分配和释放各种资源;
  • 操作系统必须保护每个进程的数据和物理资源;
  • 一个进程的功能和执行结果必须与执行速度无关。

进程的交互

  • 进程间互相不知道对方存在:操作系统需要处理进程间的资源竞争
    互斥、死锁、饥饿
  • 进程通过共享对象等间接知道对方的存在:进程间通过共享合作
    互斥、死锁、饥饿、数据一致性
  • 进程见有可用的通信原语:进程间通过通信合作
    死锁、饥饿

互斥

互斥的要求

  • 对相关进程的执行速度和处理器的数目没有任何要求和限制;

  • 一个在非临界区停止的进程不能干涉其他进程;

  • 强制互斥,忙则等待

    • 必须强制实施互斥;
    • 进程在临界区执行时其它进程等待
    • 临界区进程退出后,从多个请求进程中选择一个进入临界区
  • 有限等待

    • 一个进程在临界区内的时间有限

    • 不允许出现需要访问临界区的进程被无限延迟的情况;

  • 有空即进

    • 当没有进程在临界区时,任何需要进入临界区的进程必须能够立即进入;
  • 让权等待:进程不能进入临界区时应释放CPU资源避免忙等

解决互斥问题的方法

  • 软件方法
    由并发执行的进程担负解决问题的责任;
  • 硬件方法
    • 中断禁用
    • 专用机器指令
  • 操作系统或程序设计语言中提供某种级别的支持
    • 信号量
    • 管程
    • 消息传递
硬件方法
中断禁用1
while (true){
   /* 禁用中断 */;
   /* 临界区 */;
   /* 启用中断 */;
   /* 其余部分 */;
  }

问题:

  • 代价非常高(需要调用中断方法,这是内核态才能进行的高时间花销操作);
  • 不能用于多处理器结构中。
TestAndSet指令(TSL)

“每个临界资源设置一个共享布尔变量lock,lock=true表示正在被占用,初值设为false。在进程访问临界资源之前,利用TestAndSet检查和修改标志lock,如果有进程在临界区,则重复检查,直到进程退出

bool TestAndSet(bool* lock)
{
    bool old;//用于存放*lock原来的值    
    old=*lock;    
    *lock=true;//无论是否加锁,一律设为true    
    return old;//返回lock原来的值
}

int main(){
	while TestAndSet(&lock);//上锁检查
	//临界区代码段
	lock=false;//解锁、退出区
	//剩余区代码段
}

具体过程

  • 如果刚开始lock=false,则TSL返回的old就是false,while条件不满足,直接进入临界区
  • 如果刚开始lock=true,则TSL返回的old就是true,while条件满足,会一直循环,直到当前访问临界区的进程在退出区进行解锁

优缺点

  • 优点:相比软件方法,TSL把上锁和检查操作使用硬件的方式编程了原子操作。所以实现简单,不会像软件实现那样产生逻辑漏洞
  • 缺点不满足让权等待 ,暂时无法进入临界区的进程会占用CPU并循环执行TSL,使CPU忙等
比较和交换指令
int compare_and_swap (int *word, int testval, int newval)
{
	int oldval;
	oldval = *word;
	if (oldval == testval) *word = newval;
	return oldval;
}
/*

*/
交换指令
void exchange (int register, int memory)
{
	int temp;
	temp = memory;
	memory = register;
	register = temp;
}

两套套互斥协议,分别使用比较交换和互换指令,如下:

image-20230316223215838\ image-20230316235334406

机器指令方法(上述三种方法)的优缺点:

优点

  • 适用于在单处理器或共享内存的多处理器上的任何数目的进程;
  • 非常简单且易于证明;
  • 可用于支持多个临界区,每个临界区可以用它自己的变量定义。

缺点

  • 忙等待;
  • 可能饥饿;
  • 可能死锁。

信号量

信号量是操作系统提供的一种协调共享资源访问的方法。

通常信号量表示资源的数量,对应的变量是一个整型(sem)变量。该变量的定义如下。其中count

另外,还有两个原子操作的系统调用函数来控制信号量的,分别是:

  • P 操作semWait(sem)原语:将 sem1,相减后,如果 sem < 0,则进程/线程进入就绪队列,否则继续,表明 P 操作可能会阻塞;
  • V 操作semSignal(sem)原语:将 sem1,相加后,如果 sem <= 0,(从阻塞队列)唤醒一个等待中的进程/线程,表明 V 操作不会阻塞;
  • 下为原语定义:
strcut semaphore{
	int count;
	queueType queue;
};

void semWait(semaphore s) {
	s.count--;
	if(s.count < 0) {
		//place this process in s.queue;
		//block this process;
	}
}

void semSignal(semaphore s) {
	s.count ++;
	if(s.count <= 0) {
		//remove a process P from s.queue;
		//place process P on ready list;
	}
}

为了信号量的更为准确,可设定信号量为布尔变量,只可以取0和1,此时称为二元信号量

以下图片展示了三个进程A、B、C在信号量作用下的同步和互斥操作

s

用信号量实现的一个互斥操作

image-20230316233427311

使用信号量实现临界区的互斥访问

  • 为每类共享资源设置一个信号量 s,其初值为 1,表示该临界资源未被占用。

  • 只要把进入临界区的操作置于P semWait(s)semSignal(s) 之间,即可实现进程/线程互斥:

此时,任何想进入临界区的线程,必先在互斥信号量上执行 P 操作,在完成对临界资源的访问后再执行 V 操作。由于互斥信号量的初始值为 1,故在第一个线程执行 P 操作后 s 值变为 0,表示临界资源为空闲,可分配给该线程,使之进入临界区

若此时又有第二个线程想进入临界区,也应先执行 P 操作,结果使 s 变为负值,这就意味着临界资源已被占用,因此,第二个线程被阻塞。

并且,直到第一个线程执行 V 操作,释放临界资源而恢复 s 值为 0 后,才唤醒第二个线程,使之进入临界区,待它完成临界资源的访问后,又执行 V 操作,使 s 恢复到初始值 1。

关于信号量和信号量原语

信号量

一个信号量可用于n个进程的同步互斥;且只能由semWait、semSignal操作修改。

  • 用于互斥时,S初值为1,取值为1~ - (n-1)
    • (相当于临界区的通行证,实际上也是资源个数)
    • S=1:临界区可用
    • S=0:已有一进程进入临界区
    • S<0:临界区已被占用,|S|个进程正等待进入
  • 用于同步时,S初值>=0
    • S>=0:表示可用资源个数
    • S<0: 表示该资源的等待队列长度
信号原语

semWait(S):请求分配一个资源。

semSignal(S):释放一个资源。

  • semWait、semSignal操作必须成对出现。

    • 用于互斥时,位于同一进程内;

    • 用于同步时,交错出现于两个合作进程内。

  • 多个semWait操作的次序不能颠倒,否则可能导致死锁。 多个semSignal操作的次序可任意。

生产者-消费者问题:进程间的信号互斥与同步

see also:

在这里插入图片描述

生产者-消费者问题描述:

  • 生产者在生成数据后,放在一个缓冲区中;
  • 消费者从缓冲区取出数据处理;
  • 任何时刻,只能有一个生产者或消费者可以访问缓冲区;

我们对问题分析可以得出:

  • 任何时刻只能有一个线程操作缓冲区,说明操作缓冲区是临界代码,需要互斥
  • 缓冲区空时,消费者必须等待生产者生成数据;缓冲区满时,生产者必须等待消费者取出数据。说明生产者和消费者需要同步
假定缓冲区无限时
不使用信号量方法
image-20230317170659907
//producer:
while (true) {
	/* produce item v */
	b[in] = v;
	in++; 
}

//consumer:
while (true) {
 	while (in <= out) 
		/*do  nothing */;
	w = b[out];
	out++; 
	/* consume item w */
}

/*
上述代码维护了一个队列,由两个指针in和out“指挥”,in填共享资源入缓冲区,out读取资源
*/
使用信号量方法
semaphore n=0,s=1;//n、s两个信号量,n为缓冲区内总量,s为写入锁 

void producer(){
 while (true) {
	 produce();
   semWait(s);
   append();
   semSignal(s);
   semSignal(n);
 }
}

void consumer(){
 while (true) {
 	semWait(n);
   semWait(s);
   take();
   semSignal(s);
   consume();
 }
}
缓冲区有限
image-20230317170732278
//producer:
while (true) {
	  /* produce item v */
	while ((in + 1) % n == out) //in指针的后一位为取值的out指针	
    /* do nothing */;
	b[in] = v;
	in = (in + 1) % n //用取模的方法得循环队列
}

//consumer:
while (true) {
	while (in == out)
		/* do nothing */;
	w = b[out];
	out = (out + 1) % n;
	   /* consume item w */
}


信号量处理
semaphore n=0,s=1,e=buf-size;
void producer(){
 while (true) {
	 produce();
   semWait(e);
   semWait(s);
   append();
   semSignal(s);
   semSignal(n);
 }
}

void consumer(){
 while (true) {
 	 semWait(n);
   semWait(s);
   take();
   semSignal(s);
   semSignal(e);
   consume();
 }
}
拓展:多生产者多消费者问题

桌子上有一个盘子,可以存放一个水果。父亲总是放苹果到盘子中,而母亲总是放香蕉到盘子中;儿子专等吃盘中的香蕉,而女儿专等吃盘中的苹果。

生产者-消费者问题的一种变形,生产者、消费者以及放入缓冲区的产品都有两类,但每类消费者只消费其中固定的一种产品。

semaphore dish=1, apple=0 ,banana=0;
/*
* dish:表示盘子是否为空,初值为1
* apple:表示盘中是否有苹果,初值为0
* banana:表示盘中是否有香蕉,初值为0
* 三个信号量皆为二元信号量
*/
//碟子为缓冲区,只有一格子,算是一种极端情况

process father(){
    //生产者1
    semWait(dish);
     将苹果放到盘中;
    semSignal(apple);
}
process mother(){
    //生产者2
    semWait(dish);
     将香蕉放到盘中;
    semSignal(banana);
}

process son(){
    semWait(banana);
     从盘中取出香蕉;
    semSignal(dish);//空碟子信号
}

process daughter(){
    semWait(apple);
     从盘中取出苹果;
    semSignal(dish);//空碟子信号
}

拓展:公交车问题

设公共汽车上,司机和售票员活动如下:

1)司机:启动汽车,正常行车,到站停车;

2)售票员:关车门,售票,开门上下客。

用信号量操作描述司机和售票员的同步。

司机必须在售票员关门后才可以启动汽车,售票员必须在停车后才能开门

img

semaphore Sl=0;   //司机启动汽车
semaphore S2=0;	//售票员开门
main()
{  
	cobegin
    	driver();
   		busman();
    coend
}
        
driver()                              
{  
	 while(1) 
	{                                 
       P(S1); //初始S1=0,P(S1)后S1=-1,司机无法启动车辆,需等待售票员执行关门操作(V(S1)),需售票员关好门,唤醒司机,司机才能启动车辆                                
       启动车辆; 
       正常行车;
       到站停车;
       V(S2);  //汽车到站,唤醒售票员开车门
    }           
}
    
busman()
{
	while(1){
		关车门;
		V(S1);	//售票员已关好车门,执行V(S1),唤醒司机启动车辆
		售票;
		P(S2);	//售票员打开车门,需S2>0时。初始S2=0,售票员不能打开车门,需等司机执行V(S2)操作,唤醒售票员开门,售票员才能打开车门
		开车门;
		乘客上下车;
	}
}

读者-写者问题:读写者同步

读者-写者问题

有一个由多个进程共享的数据区,一些进程只读取(不改变)这个数据区中的数据,一些进程只往数据区中写数据。并须满足以下条件:

  • 任意多的读进程可以同时读文件;
  • 一次只有一个写进程可以写文件;
  • 如果一个写进程正在写文件,那么禁止任何读进程读文件。

读者优先:只要有读进程在读,就为读进程保留数据区的控制权。

写者优先:当写进程想写的时候,不允许新的读进程再访问数据区。

读者优先
void reader(){
     int readcount=0;
	semaphore x=1,wsem=1;//x:读者锁信号量;wsem:写者锁信号量
	void reader(){
 	while (true) {
	 	semWait(x);
    	readcount++;
    	if(readcount==1)
      		semWait(wsem);
   		semSignal(x);
    	READUNIT();   
		semWait(x);
 		readcount--;
 		if(readcount==0)
   			semSignal(wsem);
		semSignal(x);
	}
}
    
void writer(){
 while (true) {
 	 semWait(wsem);
    WRITEUNIT();
   semSignal(wsem);
 }
}

写者优先
void reader(){
    int readcount=0;
    writecount=0;
semaphore x=1,y=1,z=1,   //x: 对readcount的互斥访问 y:对writecount的互斥访问 z:写者之间互斥      
          rsem=1,wsem=1;
void reader(){
 while (true) {
	semWait(z);
	semWait(rsem);
    semWait(x);
    readcount++;
    if(readcount==1)
    semWait(wsem);
    semSignal(x);  
	semSignal(rsem); 
    semSignal(z); 
	READUNIT();
semWait(x);
 readcount--;
 if(readcount==0)
   semSignal(wsem);
semSignal(x);
 }
}
    
void writer(){
 while (true) {
 	semWait(y); 
    writecount++;
     if(writecount==1) semWait(rsem);
  semSignal(y);  
  semWait(wsem);
   WRITEUNIT();
  semSignal(wsem);
  semWait(y); 
    writecount--;
     if(writecount==0) semSignal(rsem);
  semSignal(y);  
  }
}

拓展:阅览室问题

假设一个阅览室有100个座位,没有座位时读者在阅览室外等待;每个读者进入阅览室时都必须在阅览室门口的一个登记本上登记座位号和姓名,然后阅览,离开阅览室时要去掉登记项。每次只允许一个人登记或去掉登记。用信号量操作描述读者的行为。

登记->进入->阅读->撤销登记->离开,所以建立一个读者模型即可。

临界资源有:座位,登记表。读者间有座位和登记表的互斥关系,所以设信号量empty表示空座位的数量,初始为100,mutex表示对登记表的互斥访问,初始为1。类似写者优先的问题

semaphore mutex,empty;
mutex==1; empty==100;

Procedure reader(readerID);
{
	semWait(empty);	//确认有空座位
	semWait(mutex);	//登记表前面有没有人
	registrationInformation(readerID);
	semSignal(mutex);//登记完了,给下一个人登记
	reading();
	semWait(mutex);	//签退开始
	cancelRecord(readerID);
	semSignal(mutex);//签退结束
	V(empty);	//空座位数++
}

拓展:独木桥问题

东、西向汽车过独木桥。桥上无车时允许一方汽车过桥,待全部过完后才允许另一方汽车过桥。用信号量操作写出同步算法。(提示:参考读者优先的解法)

semaphore wait=1, mutex1=1, mutex2=1; //mute:车辆数写入锁
	int count1=0, count2=0; 

process P east(){
   	semWait(mutex1);
     count1++;  //等待队列++
     if(count1==1)   semWait(wait);//等待队列开始有车时看一下另一边有没有通过中
     semSignal(mutex1);
     //through the singal-log bridge;
     semWait(mutex1);
     count1--;
     if(count1==0)   semSignal(wait);s
     semSignal(mutex1);
}

process P west(){
   	semWait(mutex2);
     count2++;
     if(count2==1)   semWait(wait);
     semSignal(mutex2);
     //through the singal-log bridge;
     semWait(mutex2);
     count2--;
     if(count2==0)   semSignal(wait);
     semSignal(mutex2);
}

管程

Moniters,也称为监视器

管程是一个或多个过程、一个初始化序列和局部数据组成的软件模块,主要特点如下:

  • 局部数据变量只能被管程的过程访问,任何外部过程都不能访问;
  • 一个进程通过调用管程的一个过程进入管程;
  • 在任何时候,只能有一个进程在管程中执行,调用管程的任何其他进程都被阻,以等待管程可用。

管程通过条件变量提供对同步的支持。条件变量只有在管程中才能被访问

以下是管程中使用条件变量c的两个原语:

  • cwait© :调用管程的进程在条件c上阻塞。
  • csignal© :恢复执行在cwait之后因为某些条件而阻塞的进程。

注意,管程的wait和signal操作与信号量不同。若管程中的一个进程发信号,但没有在这个条件变量上等待的任务,则丢弃这个信号。

与那些通过修改数据结构实现互斥访问的并发程序设计相比,管程实现很大程度上简化了程序设计。相较于信号量,条件变量或者管程把具体的同步互斥关系实现封装了起来,只暴露两个特别简单的接口以供程序员调用,而这两个接口其反应的本质问题就是资源是否存在,能否互斥访问的问题,程序员不用关心复杂的同步互斥关系,只关心在相应的程序逻辑下资源数目的问题

管程提供了一种机制,线程可以临时放弃互斥访问,等待某些条件得到满足后,重新获得执行权恢复它的互斥访问。即:在管程中的线程可以临时放弃管程的互斥访问,让其他线程进入到管程中来

管程的结构

管程的结构.jpg

用管程实现互斥和同步

实现有限缓冲区的生产者-消费者

在这里插入图片描述

进程间消息传递

消息传递:合作进程之间进行信息交换。

消息传递原语

对称寻址方式
  • send (destination, message)
  • receive (source, message)
非对称寻址方式

接收进程可能需要与多个发送进程通信,无法事先指定发送进程。因此,在接受进程的原语中,不需要命名发送进程,只填写表示源进程的参数,即完成通信后的返回值,而发送进程仍需要命名接收进程。

send(receiver,message);
receive(id,message); //接收来自任何进程的消息,id变量可设置为进行通信的发送方进程id或名字。

同步方式

  1. 阻塞send,阻塞receive:发送者和接收者都被阻塞,直到完成信息的投递。
  2. 无阻塞send,阻塞receive:接收者阻塞,直到请求的信息到达。
  3. 无阻塞send,无阻塞receive:不要求任何一方等待。

消息块的结构

Screenshot_2023-03-23-09-45-12-864_cn.wps.moffice_eng.png

Linux中的IPC结构:

struct ipc_perm 
{ 
    __kernel_key_t  key; 
    //IPC key,ipc对象的外部名,是一个独一无二的整数,用来确保ipc对象的唯一性
    __kernel_uid_t  uid; 
    __kernel_gid_t  gid; 
    __kernel_uid_t  cuid; 
    __kernel_gid_t  cgid; 
    __kernel_mode_t mode;  
    unsigned short  seq; 
};

寻址方式

直接寻址
  • send原语包含目标进程的标识号;
  • receive原语可显式地指定源进程,也可不指定。
间接寻址

发送者将消息发送到合适的信箱;接收者从信箱中获得消息。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7FwLrXP8-1690343918908)(https://s2.loli.net/2023/03/23/SwLNK3mCcexXrZ9.png)]

消息排队

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-l2iwMhC2-1690343918908)(https://s2.loli.net/2023/03/23/jxNQn9adYZuEDfH.png)]

  • 先进先出
  • 优先级

利用消息传递实现互斥

const int n=/*进程数*/
void P(int i){
	message msg;
	while (true){
		receive (box, msg);
		/*临界区*/;
		send (box, msg);
		/*其余部分*/}
}
void main(){
	create mailbox (box);
	send (box, null);
	parbegin (P(1), P(2),...,P(n));
}
实现有界的生产者消费者问题

Screenshot_2023-03-23-10-26-21-868_cn.wps.moffice_eng.png

死锁

在多线程编程中,我们为了防止多线程竞争共享资源而导致数据错乱,都会在操作共享资源之前加上互斥锁,只有成功获得到锁的线程,才能操作共享资源,获取不到锁的线程就只能等待,直到锁被释放。

那么,当两个线程为了保护两个不同的共享资源而使用了两个互斥锁,那么这两个互斥锁应用不当的时候,可能会造成两个线程都在等待对方释放锁,在没有外力的作用下,这些线程会一直相互等待,就没办法继续运行,这种情况就是发生了死锁。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kbPctnSL-1690343918909)(https://s2.loli.net/2023/03/29/HM1JfpDQSs3lIRC.png)]

死锁的原理

进程图

死锁状态下的进程图
非死锁状态下的进程图

资源类型
  • 一次只能供一个进程安全地使用,并且不会由于使用而耗尽的资源。

    • 处理器、I/O通道、内存、外存、设备、文件、数据库、信号量等。
    • 死锁的实例:20230329_162943.png
      • T和D互相等待对方解锁
  • 可以被创建和销毁的资源。

    • 中断、信号(操作系统提供给进程用于干涉进程的信息,如ctrl+c)、消息、I/O缓冲区中的信息等。
    • 实例:[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-slkZyVUb-1690343918910)(https://s2.loli.net/2023/03/29/9owApBQg4ZVENy8.png)]
      互相等对方发信息同步……
资源分配图

20230329_175615.png
指向资源:需求资源
资源指向进程:被持有

例子

20230329_175619.png
黑点代表请求窗口也即资源可同时被多少进程使用

死锁的条件

死锁只有同时满足以下四种条件才会发生2

  • 互斥条件:是指只有对必须互斥使用的资源(一次只有一个进程可以使用一个资源)抢夺时才可能导致死锁。比如打印机设备就可能导致互斥,但是像内存、扬声器则不会
  • 不可剥夺/抢占条件:不可剥夺条件,是指进程所获得的资源在未使用完之前,不能由其他进程强行夺走,只能主动释放
  • 持有并等待条件:是指进程已经至少保持了一个资源,但又提出了新的资源请求,但是该资源又被其他进程占有,此时请求进程被阻塞,但是对自己持有的资源保持不放
  • 循环等待/环路等待条件:是指存在一种进程资源的循环等待链,链中的每一个进程已获得的资源同时被下一个进程所请求

死锁是一项对操作系统危害较大的问题,但是目前为止没有一个对死锁问题的跨问题的通用的解决方案,以下三种方法各自有有缺点并适用于特定环境

死锁的预防

破坏死锁产生的四个必要条件中的一个或几个。

互斥的预防

如果把只能互斥使用的资源改造为允许共享使用,则系统不会进入死锁状态。但并不是所有资源都可以改造为成共享使用的资源的,而且为了系统安全性,很多地方也是禁止改造的,所以互斥条件一般无法破坏

不可剥夺条件的预防
  • 如果占有某些资源的进程进一步申请资源时被拒绝,则该进程必须释放它最初占有的资源。
  • 如果进程A请求当前被进程B占有的一个资源,则操作系统可以抢占进程B,要求它释放资源。
  • 存在的问题:
    • 实现起来比较复杂
    • 释放已获得的资源可能造成前一阶段工作的失效,所以这种方法一般只适用于易保存和恢复状态的资源,比如CPU
    • 反复申请和释放资源会增加系统开销,降低系统吞吐量
    • 若采用方法一,意味着只要暂时得不到某个资源,之前获得的那些资源都需要放弃,以后再重新申请,容易导致进程饥饿
占有并等待条件的预防

可要求进程一次性地请求所有需要的资源,并且阻塞进程直到所有请求都同时满足。
存在的问题:

  • 一个进程可能被阻塞很长时间,已等待满足其所有的资源请求。
  • 分配给一个进程的资源可能有相当长的一段时间不会被使用,因此如果进程的整个运行期间都一直保持着所有资源,就会造成严重的资源浪费,资源利用率极低,并且该策略也有可能导致饥饿现象。
  • 一个进程可能事先并不知道它所需要的全部资源
循环等待的预防

可以采用顺序资源分配方法。首先给系统中的资源分类、确定优先级并进行编号,规定每个进程必须按照编号递增的顺序请求资源,编号相同的资源(也就是同类资源)一次申请完

  • 如果一个进程已经分配到了R类型的资源,那么它接下来请求的资源只能是那些排在R类型之后的资源类型
  • 存在的问题
    • 可能会导致程序变慢
    • 可能会导致不必要的拒绝访问资源

死锁的避免

如果一个进程的请求会导致死锁,则不启动此进程;如果一个进程增加的资源请求会导致死锁,则不允许此分配。

允许进程动态申请资源,但系统在进行资源分配的时候,应该先计算此次分配的安全性,如若此次分配不会导致系统进入不安全状态,则允许分配,否则等待

安全状态和安全序列

安全状态是指系统能按照某种进程推进顺序 P 1 , P 2 , … , P n P_1,P_2,\ldots,P_n P1,P2,,Pn,为每个进程分配其所需要的资源,直到满足每个进程资源的最大需求,使每个进程都可以额顺序完成。其中 P 1 , P 2 , … , P n P_1,P_2,\ldots,P_n P1,P2,,Pn称之为安全序列

  • 只要能找出一个安全序列,系统就是安全状态;分配资源后若找不出任何一个安全序列,则系统进入了不安全状态
  • Screenshot_2023-04-05-12-12-10-529_cn.wps.moffice_eng.png
    • 不安全状态不一定导致死锁,但是安全状态一定不会死锁
  • 安全序列可能有多个
进程启动拒绝

第n+1个进程对资源j的需求,应该满足:前n个进程的最大资源需求加上当第n+1个进程的资源需求,应该小于等于总资源量。

该方法很难是最优的,因为它总是假设最坏的情况: 所有的进程同时发出其最大资源请求

资源分配拒绝-银行家算法

银行家算法(Banker’s Algorithm)是一个避免死锁(Deadlock)的著名算法,是由艾兹格·迪杰斯特拉在1965年为T.H.E系统设计的一种避免死锁产生的算法。它以银行借贷系统的分配策略为基础,判断并保证系统的安全运行。
  在银行中,客户申请贷款的数量是有限的,每个客户在第一次申请贷款时要声明完成该项目所需的最大资金量,在满足所有贷款要求时,客户应及时归还。银行家在客户申请的贷款数量不超过自己拥有的最大值时,都应尽量满足客户的需要。在这样的描述中,银行家就好比操作系统,资金就是资源,客户就相当于要申请资源的进程。
  银行家算法是一种最有代表性的避免死锁的算法。在避免死锁方法中允许进程动态地申请资源,但系统在进行资源分配之前,应先计算此次分配资源的安全性,若分配不会导致系统进入不安全状态,则分配,否则等待。为实现银行家算法,系统必须设置若干数据结构。

数据结构
  • Available(可用):Available[j]=k
    资源类型Rj 现有k个实例
  • Claim(请求,部分教材写作Max即最大需求): Claim[i,j]=k
    进程Pi最多可申请k个Rj的实例
  • Allocation(分配,划拨):Allocation[i,j]=k
    进程Pi现在已经分配了k个Rj的实例
  • Need(需求):Need[i,j]=k
    进程Pi还可能申请k个Rj的实例
  • Need[i,j] = Claim[i,j] - Allocation[i,j]
符号
  • X<=Y
    (X和Y是长度为n的向量),当且仅当对所有i=1,2,…,n,X[i]<=Y[i]
  • Allocationi
    • 表示分配给进程Pi的资源(将Allocation每行作为向量)
    • Need同Allocation
安全性算法

用于确定计算机系统是否处于安全状态

  1. 设Work和Finish分别是长度为m和n的向量,初始化Work:=Available(work:工作中可分配资源),Finish[i]=false(i=1,2,…,n)
  2. 查找 i 使其满足
  • Finish[i] = false
  • Needi <=Work
    若没有这样的 i 存在,转到4)。
  1. Work := Work + Allocationi(进程分配了的资源还回可分配矩阵) Finish[i] := true
    返回到2)
  2. 如果对所有 i,Finish[i] = true,则系统处于安全状态

满足条件 Finish[i] = false 和 Need[i,j] ≤ Work[j] 的,必定也会按照预期的将 Finish[i] 向量全部置为true,所占用资源在结束后尽数返回,那是不是就可以设置一个变量来累加计数,当该变量与进程数量相等的时候,就说明已经全部置为true了,终止循环,也就是说系统安全。否则,若系统不安全,则这样的过程不能执行,因总会有finish[i]无法设置为true

资源请求算法

设Requesti 为进程Pi的请求向量

  1. 如果Requesti <= Needi ,那么转到第2步。否则,产生出错条件,因为进程已超过了其请求。
  2. 如果Requesti <=Available,那么转到第3步。否则, Pi等待,因为没有可用资源。
  3. 假定系统可以分配给进程Pi 所请求的资源,并按如下方式修改状态:
    Available:= Available – Requesti ;
    Allocationi := Allocationi + Requesti ;
    Needi := Needi – Requesti ;
    4.调用安全性算法确定新状态是否安全(先推定分配再判断如此分配的安全性)
    • 安全—操作完成且进程Pi分配到其所需要的资源
    • 不安全—进程Pi必须等待,并将数据结构恢复到原状态(即 3的逆操作)
一个C语言模拟
#include<stdio.h>
#define resourceNum 3
#define processNum  5

//系统可用(剩余)资源
int available[resourceNum]={3,3,2};
//进程的最大需求
int maxRequest[processNum][resourceNum]={{7,5,3},{3,2,2},{9,0,2},{2,2,2},{4,3,3}};
//进程已经占有(分配)资源
int allocation[processNum][resourceNum]={{0,1,0},{2,0,0},{3,0,2},{2,1,1},{0,0,2}};
//进程还需要资源
int need[processNum][resourceNum]={{7,4,3},{1,2,2},{6,0,0},{0,1,1},{4,3,1}};
//是否安全
bool Finish[processNum];
//安全序列号
int safeSeries[processNum]={0,0,0,0,0};
//进程请求资源量
int request[resourceNum];
//资源数量计数
int num;

//打印输出系统信息
void showInfo()
{
	printf("\n------------------------------------------------------------------------------------\n");  
	printf("当前系统各类资源剩余:");
    for(int j = 0; j < resourceNum; j++)
	{
        printf("%d ",available[j]);
    }
    printf("\n\n当前系统资源情况:\n");
    printf(" PID\t Max\t\tAllocation\t Need\n");
    for(int i = 0; i < processNum; i++)
	{
        printf(" P%d\t",i);
        for(int j = 0; j < resourceNum; j++)
		{
            printf("%2d",maxRequest[i][j]);
        }
        printf("\t\t");
        for(int j = 0; j < resourceNum; j++)
		{
            printf("%2d",allocation[i][j]);
        }
        printf("\t\t");
        for(int j = 0; j < resourceNum; j++)
		{
            printf("%2d",need[i][j]);
        }
        printf("\n");
    }
}

//打印安全检查信息
void SafeInfo(int *work, int i)
{
    int j;
    printf(" P%d\t",i);
    for(j = 0; j < resourceNum; j++)
	{
        printf("%2d",work[j]);
    }   
    printf("\t\t");
    for(j = 0; j < resourceNum; j++)
	{
        printf("%2d",allocation[i][j]);
    }
	printf("\t\t");
    for(j = 0; j < resourceNum; j++)
	{
        printf("%2d",need[i][j]);
    }
    printf("\t\t");
    for(j = 0; j < resourceNum; j++)
	{
        printf("%2d",allocation[i][j]+work[j]);
    }
    printf("\n");
}

//判断一个进程的资源是否全为零
bool isAllZero(int kang)
{
	num = 0;
	for(int i = 0; i < resourceNum; i++ )
	{
		if(need[kang][i] == 0)
		{
			num ++;
		}
	}
	if(num == resourceNum)
	{
		return true;
	}
	else
	{
		return false;
	}   
}

//安全检查
bool isSafe()
{
	//int resourceNumFinish = 0;
	int safeIndex = 0;
	int allFinish = 0;
    int work[resourceNum] = {0};
	int r = 0;
	int temp = 0;
	int pNum = 0;
	//预分配为了保护available[]
    for(int i = 0; i < resourceNum; i++)
	{		
        work[i] = available[i];	
    }
	//把未完成进程置为false
    for(int i = 0; i < processNum; i++)
	{
		bool result = isAllZero(i);
		if(result == true)
		{
			Finish[i] = true;
			allFinish++;
		}
		else
		{
			Finish[i] = false;
		}

    }
	//预分配开始
    while(allFinish != processNum)
	{
		num = 0;	
        for(int i = 0; i < resourceNum; i++)
		{
			if(need[r][i] <= work[i] && Finish[r] == false)
			{
				num ++;
			}			
		}
		if(num == resourceNum)
		{		
			for(int i = 0; i < resourceNum; i++ )
			{
				work[i] = work[i] + allocation[r][i];
			}
			allFinish ++;
			SafeInfo(work,r);
			safeSeries[safeIndex] = r;
			safeIndex ++;
			Finish[r] = true;
		}
		r ++;//该式必须在此处	
		if(r >= processNum)
		{
			r = r % processNum;
			if(temp == allFinish)
			{
				break;	
			}
			temp = allFinish;
		}		
		pNum = allFinish;
    }	
	//判断系统是否安全
	for(int i = 0; i < processNum; i++)
	{
		if(Finish[i] == false)
		{
			printf("\n当前系统不安全!\n\n");
			return false;	
		}
	}
	//打印安全序列
	printf("\n当前系统安全!\n\n安全序列为:");
	for(int i = 0; i < processNum; i++)
	{	
		bool result = isAllZero(i);
		if(result == true)
		{		
			pNum --;
		}	
    }
	for(int i = 0; i < pNum; i++)
	{
		printf("%d ",safeSeries[i]);
	}
    return true;
}

//主函数
int main()
{
    int curProcess = 0;
	int a = -1;
       showInfo(); 
	printf("\n系统安全情况分析\n");
	printf(" PID\t Work\t\tAllocation\t Need\t\tWork+Allocation\n");
	bool isStart = isSafe();
	//用户输入或者预设系统资源分配合理才能继续进行进程分配工作
    while(isStart)
	{
		//限制用户输入,以防用户输入大于进程数量的数字,以及输入其他字符(乱输是不允许的)
      	do
		{ 
			if(curProcess >= processNum || a == 0)
			{
				printf("\n请不要输入超出进程数量的值或者其他字符:\n");
				while(getchar() != '\n'){};//清空缓冲区	
				a = -1;
			}
			printf("\n------------------------------------------------------------------------------------\n");
			printf("\n输入要分配的进程:");
			a = scanf("%d",&curProcess);
			printf("\n");

		}while(curProcess >= processNum || a == 0);
		
		//限制用户输入,此处只接受数字,以防用户输入其他字符(乱输是不允许的)
		for(int i = 0; i < resourceNum; i++)
		{
			do
			{
				if(a == 0)
				{
					printf("\n请不要输入除数字以外的其他字符,请重新输入:\n");
					while(getchar() != '\n'){};//清空缓冲区	
					a = -1;
				}
				printf("请输入要分配给进程 P%d 的第 %d 类资源:",curProcess,i+1);
				a = scanf("%d", &request[i]);
			}while( a == 0);
		}

		//判断用户输入的分配是否合理,如果合理,开始进行预分配
		num  = 0;
        for(int i = 0; i < resourceNum; i++)
		{
            if(request[i] <= need[curProcess][i] && request[i] <= available[i])
			{
				num ++;
			}
            else
			{
				printf("\n发生错误!可能原因如下:\n(1)您请求分配的资源可能大于该进程的某些资源的最大需要!\n(2)系统所剩的资源已经不足了!\n");
				break;
			}
        }
        if(num == resourceNum)
		{	
			num = 0;	
            for(int j = 0; j < resourceNum; j++)
			{
				//分配资源
                available[j] = available[j] - request[j];
                allocation[curProcess][j] = allocation[curProcess][j] + request[j];
                need[curProcess][j] = need[curProcess][j] - request[j];
				//记录分配以后,是否该进程需要值为0了
				if(need[curProcess][j] == 0)
				{
					num ++;
				}
            }
			//如果分配以后出现该进程对所有资源的需求为0了,即刻释放该进程占用资源(视为完成)
			if(num == resourceNum)
			{
				//释放已完成资源
				for(int i = 0; i < resourceNum; i++ )
				{
					available[i] = available[i] + allocation[curProcess][i];
				}
				printf("\n\n本次分配进程 P%d 完成,该进程占用资源全部释放完毕!\n",curProcess);
			}
			else
			{
				//资源分配可以不用一次性满足进程需求
				printf("\n\n本次分配进程 P%d 未完成!\n",curProcess);
			}

			showInfo();
           	printf("\n系统安全情况分析\n");
			printf(" PID\t Work\t\tAllocation\t Need\t\tWork+Allocation\n");

			//预分配完成以后,判断该系统是否安全,若安全,则可继续进行分配,若不安全,将已经分配的资源换回来
            if(!isSafe())
			{ 	        
				for(int j = 0; j < resourceNum; j++)
				{
					available[j] = available[j] + request[j];
					allocation[curProcess][j] = allocation[curProcess][j] - request[j];
					need[curProcess][j] = need[curProcess][j] +request[j];
				}
				printf("资源不足,等待中...\n\n分配失败!\n");				
            }
        }
    }
    return 0;
}

死锁的检测

死锁检测策略不限制资源访问或约束进程行为。
系统周期性地执行检测算法,检测循环等待条件是否成立。

检测时机
  • 法1.每个资源请求发生时
    • 优点:可以尽早地检测死锁情况
    • 缺点:频繁的检查会耗费相当多的处理器时间
  • 法2.隔一段时间
算法

新定义一个请求矩阵Q

  1. 标记Allocaiton矩阵中一行全为零的进程(没分配任何资源)。
  2. 初始化一个临时向量W,令W等于Available向量。
  3. 查找下标i,使进程i当前未标记且Q的第i行小于等于W,如果找不到这样的行,终止算法。(进程i的所需小于W)
  4. 如果找到这样的行,标记进程i,并把Allocation矩阵中的相应行加到W中,返回步骤3.

若最后有未标记的进程时,存在死锁,每个未标记的进程都是死锁的。

死锁的撤销

  1. 取消所有死锁进程。
  2. 把每个死锁进程回滚到某些检查点,并重新启动所有进程。
  3. 连续取消死锁进程直到不再存在死锁。选择取消进程的顺序基于某种最小代价原则。每次取消后,必须重新调用死锁检测算法,以测试是否仍存在死锁。
  4. 连续抢占资源直到不再存在死锁。同3。

一种综合的死锁策略

  1. 可交换空间
    通过要求一次性分配所有请求的资源来预防死锁,是死锁避免
  2. 进程资源
    法一:死锁避免;法二:资源排序的死锁预防
  3. 内存
    基于抢占的预防
  4. 内部资源
    基于资源排序的预防

哲学家就餐

是对于互斥访问有限的竞争问题(如 I/O 设备)的建模。这是一个容易死锁的问题。

该问题描述的是五个哲学家共用一张圆桌,分别坐在周围的五张椅子上,在圆桌上有五个碗和五根筷子3,他们的生活方式是交替的进行思考和进餐。平时,一个哲学家进行思考,饥饿时便试图取用其左右最靠近他的筷子,只有在他拿到两只筷子时才能进餐。进餐完毕,放下筷子继续思考。
20230405_165231.png

因为是五位哲学家,并且每位哲学家的各自做自己的事情(思考和吃饭),因此可以创建五个线程表示五位哲学家,五个线程相互独立(异步)。并对五位哲学家分别编号为0~4。
​ 同时,有五根筷子,每根筷子只对其相邻的两位哲学家是共享的,因此这五根筷子可以看做是五种不同的临界资源(不是一种资源有5个,因为每根筷子只能被固定编号的哲学家使用)。并对五根筷子分别编号为0~4,其中第i号哲学家左边的筷子编号为i,则其右边的筷子编号就应该为(i + 1) % 5。
因为筷子是临界资源,因此当一个线程在使用某根筷子的时候,应该给这根筷子加锁,使其不能被其他进程使用。

不考虑死锁状态的情况下的处理
semaphore mutex[5] = {1,1,1,1,1}; 		//初始化信号量

void philosopher(int i){
  do {
    //thinking			//思考
    P(mutex[i]);//判断哲学家左边的筷子是否可用
    P(mutex[(i+1)%5]);//判断哲学家右边的筷子是否可用
    //...
    //eat		//进餐
    //...
    V(mutex[i]);//退出临界区,允许别的进程操作缓冲池
    V(mutex[(i+1)%5]);//缓冲池中非空的缓冲区数量加1,可以唤醒等待的消费者进程
  }while(true);
}

这种状况下,过了一段时间哲学家们就会死锁,因一个人拿着左边的筷子等着右边的筷子,右边的人拿着(他)左边的筷子也等着右边的……当然简单的解决方案是筹齐5双筷子直接一人一双,但是计算机里未必能凑足10个共享资源……

用信号量方法解决死锁
  1. 一次只容纳4人同时吃饭,用room信号量确定有否空余座位
semaphore mutex[5] = {1,1,1,1,1}; 		//初始化信号量
semaphore room = 4;	//控制最多允许四位哲学家同时进餐

void philosopher(int i){
  do {
    //thinking		//思考
    p(room);		//判断是否超过四人准备进餐
    P(mutex[i]);	//判断缓冲池中是否仍有空闲的缓冲区
    P(mutex[(i+1)%5]);//判断是否可以进入临界区(操作缓冲池)
    //...
    //eat			//进餐
    //...
    V(mutex[i]);//退出临界区,允许别的进程操作缓冲池
    V(mutex[(i+1)%5]);//缓冲池中非空的缓冲区数量加1,可以唤醒等待的消费者进程
    V(room);//用餐完毕,别的哲学家可以开始进餐
  }while(true);
}

  1. 给左右筷子的获取开个顺序
semaphore mutex[5] = {1,1,1,1,1}; 		//初始化信号量

void philosopher(int i){
  do {
    //thinking	
    if(i%2 == 1){
      P(mutex[i]);//判断哲学家左边的筷子是否可用
      P(mutex[(i+1)%5]);//判断哲学家右边的筷子是否可用
    }else{
      P(mutex[(i+1)%5]);//判断哲学家右边的筷子是否可用
      P(mutex[i]);//判断哲学家左边的筷子是否可用
    }
    //...
    //eat
    //...
    V(mutex[i]);//退出临界区,允许别的进程操作缓冲池
    V(mutex[(i+1)%5]);//缓冲池中非空的缓冲区数量加1,可以唤醒等待的消费者进程
  }while(true);
}

管程方法解决死锁

unix并发机制

管道通信机制

POSIX系统特有的通信机制,来自UNIX。

管道是UNIX中一种古老的通信方式,管道本质其实是一个文件

在这里插入图片描述

如上,命令行who的标准输出原本是屏幕,但是却输出到了管道文件中,发生了重定向,然后wc命令再从以管道文件作为标准输入,然后输出到屏幕中
其中who | wc -l这种属于匿名管道

管道分为:匿名管道通信、高级管道通信、有名管道通信

  • 匿名管道( pipe ):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。父进程可以往管道里写,子进程可以从管道里读。管道由环形队列实现。
  • 高级管道( popen ):将另一个程序当做一个新的进程在当前程序进程中启动,则它算是当前程序的子进程,这种方式我们成为高级管道方式。
  • 有名管道 (named pipe) : 有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信

消息队列

消息队列是由有类型的消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。

共享内存

两个进程使用同一块内存空间

基于数据结构的共享

共享数组,共享指针等,速率较慢且跨语言、跨平台特性差

内存共享区

在这里插入图片描述

信号

用于异步通信,尤其是异常情况的通信。是操作系统向进程发送异常情况以干涉进程。

死锁

POSIX不处理死锁……包括UNIX和LINUX

Linux并发机制

Linux在UNIX的并发机制的基础之上,还支持一种特殊类型的信号,实时信号。实时信号和标准UNIX信号相比有三个主要的不同点:

  • 支持按优先级顺序排列的信号进行传递
  • 多个信号能进行排队
  • 在标准的信号中,数值和消息只能视为通知,不能发送给目标进程。但实时信号可以将一个指针随信号一起发送过去

Linux还为内核提供了一套丰富的并发机制,专门为内核模式线程准备的。

原子操作

针对整数变量的整数操作以及针对位图中的某一位的操作。在单核处理器上,通过线程不能被中断实现原子,在多核处理器上通过锁住变量实现原子。

自旋锁

通过不断检查内存中的某个变量的值,若为0,置为1,进入,否则忙等。(不需要上下文的切换)。基本自旋锁具体有如下几种类型

  • 普通自旋锁:不被中断处理程序执行或者禁用中断的时候执行(不会被中断干扰)

  • _irq:中断一直被启用的时候,使用这种自旋锁。其会关闭本地处理上的中断

  • _irqsave:不知道执行时间内中断是否启用。获得锁后,本地处理器的中断状态会被保存。等到锁释放的时候会恢复这一状态

  • _bh:关闭下半部的执行。发生中断时,相应的中断处理器只处理最少量的必要工作。下半部是指一段代码执行中断相关工作的其他部分。因此允许尽快的启用当前的中断,但禁用下半部的执行。

自旋锁在单处理器下的工作原理:若关闭了内核抢占,在内核模式下不会被打断,锁在编译期就被删除了;若没有关闭内核抢占,则会被简单的实现为禁用中断。在多核处理器下,会由内核代码(swap and test)来实现。

除基本自旋锁之外,还有读写自旋锁,通过计数和标记实现更高的并发,属于读优先类型

信号量

内核的信号量对用户是不可见的。Linux提供的内核信号量有三种:二元(互斥)信号量、计数信号量和读写信号量。

针对计数信号量,Linux提供三个版本的down(semWait)的操作

  • 对应传统的semWait操作。线程测试信号量,信号量不可用时阻塞,在up操作发生时被唤醒。同样适用于二元信号量

  • down_interruptible:允许因down操作而被阻塞的线程在此期间接收并响应内核信号。若线程被唤醒,则函数down_interruptible会在增加信号量值(相当于取消了该线程的down操作)的同时返回错误代码,这将告知线程对信号量的掉用已经被取消(线程强行释放了信号量)。这个特性在设备驱动程序和其他服务中很有用,可以更加方便的回滚操作down操作。

  • down_trylock:正如字面意思

读写信号量:对读者使用一个计数信号量,对写者使用一个二元信号量。因为读写信号量使用不可中断睡眠,因此每个down只有一个版本。

屏障

RCU(Read-Copy-Update)类似版本控制的机制。通过指针的方式管理资源,每个指针上的内容只可被写者赋值一次,写者写数据,会写到一个副本中,同时将指向副本的指针更新(原子操作)到RCU管理的指针上。读者会读到指针更新前,或者更新后的数据,但是不会读到更新中的错误数据。当对一个指针的所有读者都释放了读锁,该资源会被真的释放掉。

针对读写场景

关于读写场景,自旋锁信号量屏障都给出了解决方案。

自旋锁提供的解决方案,是读优先,会造成更新延迟等问题;

信号量通过不同的实现方式,可以实现不同类型(读优先、写优先、公平读写)的读写锁,自由度高

屏障中提到的RCU,会创建副本,耗费更多的空间,但错开了读写之间的冲突,实现了更高的效率(配置中心的策略可能就是如此)。



  1. 本节所用代码均为c系语法的伪代码 ↩︎

  2. 斜杠隔开的是两种翻译法或称呼 ↩︎

  3. 源题目是两只叉子,太生草了,故改为两根筷子(改了一样生草↩︎

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

方铎极客

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值