进程同步

用的时候给我说一声。

进程与线程中提到过进程之间的联系,进程互斥是描述了进程间的间接相互作用。而进程同步则是一种直接的相互作用形式,这是合作进程之间一种有意识的行为。

进程同步


一组进程为了协调其推进速度,在某些地方需要相互等待或者唤醒,这种进程间的相互制约就被称作是进程同步。这种合作现象在操作系统和并发式编程中属于经常性事件。

开车的人一般都会经过如下步骤:开车门 -> 关车门 —> 启动车
如果车里有其他人,司机一般会在启动车前检查车门是否关好,如果没关好就得提醒乘客关好车门,然后等待车门关好再启动车。司机和乘客之间的这种动作就是一种合作进程。
我记得小时候坐的公交车是带有售票员的(现在都是自动售票了,只需要一个司机盯一下就好了),每到一个站,售票员负责开车门,下车售票,上车关门,而司机就只负责开车,停车,两人的分工倒是很明确的,记得某一次坐车,售票员中途跑去上厕所顺手把车门一关,司机一脚油门就走了,售票员在后边追半天也没追上,索性就放弃了,到了第二站的时候司机才发现售票员不见了……(那会儿的公交车小,坐的人还不少,拥挤程度有时候能和日本地铁媲美)

公交车司机和售票员需要协同工作,那么就需要一种协同工作的机制——即进程同步,而如果同步中的问题没有处理好,那么发生上面说的司机丢掉售票员的事情也可能就是常态了。

进程同步机制

信号量与PV操作

这个进程同步机制应该算的上是古董级的了,这种机制的主要思想就是——通过将资源数量化,将申请资源和释放资源的动作具体化,从而达到对资源的操作及结果可视化的程度。
信号量和PV操作的具体定义如下:

struct semaphore 
{
    int value;       //初始化值必须>=0
    pointer_to_PCB queue;           //指向PCB的队列
}       //信号量,其变量必须也只能初始化一次,只能执行PV操作

void P(semaphore *s)
{
    s->value--;
    if (s->value < 0)
        asleep(s->queue);
}       //P操作

void V(semaphore *s)
{
    s->value++;
    if (s->value <= 0)
        wakeup(s->squeue);
}      //V操作

解释几个内容:

  • queue是指向一个由PCB所构成的队列头部,当这个队列中不存在任何等待进程时,其指向为空(初始也为空)。
  • asleep和wakeup可以按照字面意理解——即休眠唤醒
    • 执行asleep(s->queue)的进程的PCB会进入queue这个等待队列的尾部,其由运行态 —> 等待态,系统转处理器调度程序。
    • 执行wakeup(s->queue)时,统一将queue队列头部的PCB取出(遵循队列先入先出原则)放入就绪队列,等待态 —> 就绪态

现在将之前所说的司机和售票员动作代码化:
司机和售票员

从中我们可以看见司机和售票员均需对方完成相应的动作才能继续进行,而S1,S2两个信号量则是将两人互相等待的动作具体化了。

可以继续深入了解一下PV操作——生产者和消费者的问题。
一家面包店货架上有K个位置,每个位置只能放一块儿面包,当货架上还有位置可以摆放面包时,面包师就会制作面包摆放上去,而只要货架上还有面包,客人就可以买走,没有面包的时候就等面包师做好面包。(小本生意,就一个窗口进行交易……)
简单的分析一下面包师和客人的动作:

do
{
    make_bread();   //做面包
    put_in();       //将面包放入货架上
} while (1);
do
{
    put_away();     //买面包
} while (1);

面包师不断地重复做面包->放到货架上的动作,客人则是不断的买面包。
很显然,由于货架位置只有K个,满了就不能再放,空了,客人就不能再买,所以面包师和客人的动作就无法无限制的重复下去,所以我们需要通过增设一个信号来通知面包师和客人能不能继续进行。
面包店算法

仅仅是这样还不够,面包师做面包的时候,有客人来是无法招待的,所以我们需要让面包师和客人实现互斥
面包师算法加强版

到目前为止,我们说的都是只有一个面包师和一个客人的的情况,但一般都是存在多个面包师以及多个客人的,那么这样一来面包师之间要实现互斥,客人之间也要实现互斥关系
面包师算法终极版

其实生产者-消费者问题使PV操作中的一个经典问题,这里所说的只是问题的一个小小的变种。
话说前几天在楼下的一家小店买卤肉卷,我付钱给老板和老板递卤肉卷给我,这都在一个小窗口完成的。
而且去的时候前边还有俩人在等,我倒也没排队,直接对老板说来份卤肉卷,微信转,然后也等起来了。(说没有这种经历的人,请开始你的表演)
这小店儿里就一个做饭的地方,两个店员就只有一个人能使用灶台(生产者互斥啦)。客人也都是讲究个先来后到,前面的人点完才能是后面的人(消费者互斥啦)。至于说货架……小本生意,点餐才做嘛(货架可放0个卤肉卷)。

再来简单的解释几个经典问题,加深对PV操作和信号量的理解:

  • 读者-写者问题
    设有一组共享数据和两组并发进程,一组数据只对此组数据执行读操作,另一组则对此组数据执行写操作。
    要求:多个读者可以同时进行读操作,多个写着不能进行写操作,读者存在则不能写
    从这两点要求上,我们可以看出,算法设计出来之后一定是读者优先,写者容易饥饿。
    读者-写者,读者优先算法

    有读者优先,那也就有写者优先,但这些存在优先的算法都是不公平的。

  • 吸烟者问题
    三个生产者提供不同的原料:
    X:提供tobacco和match,Y:提供match和wrapper,Z:提供wrapper和tobacco
    三个吸烟者有不同的原料:
    A:有tobacco,B:有match。C:有wrapper
    要求:
    同一时刻,只有一家供应商提供原料,资源耗尽后才能继续生产。

稍加思考后,我们便可以发现,X提供的原料刚好使C可以吸烟,Y提供的原料刚好可以使A可以吸烟,Z提供的原料刚好使得B可以吸烟,如果按照正常的思路将原料抽象成独立的信号量,然后交由吸烟者去申请,那么就极易出现死锁(不光吸烟者无法吸烟,生成者还不能生产,吸烟者说不定就能戒烟了,但生产者就破产了)。

一个一个的申请资源显然不行,那么我们同时申请两个,甚至所有需要的资源呢?显然可以看出,在这个问题上,这种想法是可行的。

SP(semaphore *s1, int d1 ... ,semaphore *sn, int dn)
{
    if (s1->value >= d1 && ... && sn->value >= dn)
        for (int i = 1; i < n; i++)
            s[i]->value = s[i]->value - d[i];     //di是si的增量值,即申请的资源数量
    else 
        //将运行进程的PCB连到第一个Si<di的队列中
        //将该进程的指令计数器内容设置为SP的起始位置
        //使得当前进程重新运行时可以重新对所有等待条件进行评估
}
SV(semaphore *s1, int d1, ... ,semaphore *sn, int dn)
{
    for (int i = 1; i <= n; i++)
        s[i]->value = s[i]->value -d[i];
        //将si队列上的所有PCB取出,连入就绪队列
}

至于这个如何使用,就交给各位读者自己思考了。

条件临界区

从前文中可以看出,PV操作比较灵活,普通的PV操作不行,给加强一下继续用,但是如果在某一步忘记了PV操作中的一个,就会造成死锁问题。
但是总的来说,一般的同步问题还是可以解决的,就是这种机制比较低级。所以就有人提出比较高级的条件临界区了,语法格式如下:

while (expression)
    do something to the region;

即当某一条件被满足,则可以在相应的临界区对于共享变量做出某种操作。
条件表达式expression的计算会使条件临界区的实现效率变低。

进入条件临界区的进程需要同时满足互斥和expression为真两个条件,不满足时则进程等待,致使在条件临界区的入口形成一个等待队列。究其本质,也属于一种忙式等待。

管程

PV操作不光是低级,还是分散式的同步机制,也就是说对于共享变量及信号量变量的操作分散于各个进程当中,导致程序的可读性差,局部性差,不利于程序的修改和维护(操作被分散了,就得通读整个程序,才能检测PV操作的正确性),换句话说,正确性难以保证。
于是有人不甘寂寞,由此提出了管程概念——将共享变量以及对于共享变量所能执行的操作集中在一个模块中(听起来是不是和高级语言的模块化编程概念很类似?)。

管程的几个主要特点:
1. 模块化,一个管程就是一个模块。
2. 抽象数据类型,管程是一种特殊的数据类型,不仅有数据,还有相关代码。
3. 信息掩藏,管程中的函数实现了某些功能,但具体实现对外部不可见,共享变量在管程外部不可见,类似封装的思想。

管程也分几种,但分类则是由于唤醒等待操作的处理方式不同而有了不同,假定某一进程P唤醒Q,此时:
1. P等待Q继续,直到Q退出或等待,这个方式的逻辑性强,效率较低,是Hoare管程所采用的。
2. Q等待P继续,知道P等待或退出,这个方式效率高,但逻辑性差,Java管程使用的此种方式。
3. 规定唤醒操作为管程中的最后一个可执行操作,实现起来比较简单,Hansen管程采用。

几项概念:
* 入口等待队列:设在管程入口处的等待队列。

  • 紧急等待队列:设在管程内部的进程等待队列。优先级高于入口等待队列。

    在管程内部,由于执行唤醒操作,可能会出现多个等待进程,紧急等待队列即用于管理这些进程。前提是进程已经进入管程。

  • 条件型变量:PCB_queue *c ,其指向NULL或者指向一个PCB队列头部。

    • wait(c); 如果紧急等待队列非空,则唤醒第一个等待者,否则释放管程的控制权。执行wait(c)操作的进程的PCB进入c链的尾部。
    • signal(c); 如果c链为空,则执行signal(c);操作的进程继续,否则唤醒第一个等待者。执行signal(c);操作的进程的PCB放入紧急等待队列的尾部。

现在用管程解决读者-写者的问题:

ADL语言不太容易描述管程的问题,所以改用C++或是伪代码等其他方式描述。

class ReaderWriter
{
    public:
        void StartRead();
        void FinishRead();
        void StartWrite();
        void FinishWrite();
    private:
        int readerCount = 0;
        int writerCount = 0;
        PCB_queue rq = NULL;
        PCB_queue wq = NULL;
}
void ReaderWriter::StartRead()
{
    if (writerCount > 0)
        wait(rq);
    readerCount = readerCount + 1;
    signal(rq);
}

void ReaderWriter::FinishRead()
{
    readerCount = readerCount - 1;
    if (readerCount = 0)
        signal(wq);
}

void ReaderWriter::StartWrite()
{
    writerCount = writerCount + 1;
    if (writerCount > 1 || readerCount > 0)
        wait(wq);
}

void ReaderWriter::FinishWrite()
{
    writerCount = writerCount - 1;
    if (writerCount > 0)
        signal(wq);
    else
        signal(rq);
}

这里可以看到,当有写者时,读者便不可以进入,必须等待写者完成操作。这个算法是偏向于写者的——即写者优先。

本文不再过多赘述管程,关于管程的问题,再开一文叙述。

管程虽然比较高级,但是却和PV操作是等价的,可以用管程构造PV操作,也可以用PV操作构造管程。

会合

管程比PV操作要高级一些,但是和PV操作等价,只适合与单处理器系统及具有公共内存的多处理器系统。

在分布式系统中,管程和PV操作是失败的,这是因为其二者均是以被动成分(被进程所操作的对象)为核心的,而被动成分在分布式系统中的存储难以被克服。

适合分布式系统的同步机制有通信顺序进程会合分布式进程远程过程调用等,这里由于篇幅则只介绍会合。

当一个任务调用另一个任务的入口,而且被调用者已经准备好接收这个调用时,便发生会合

任务:相当于通常所说的进程(主动成分)。一个任务有多个入口,一个入口对应一段程序,一个任务可以调用另一个任务的入口。
任务

管理独占性资源:

int resource = 1;

void require()
{
    if (resource > 0)
        resource = resource - 1;
    else
        wait();        //进入等待
}

void release()
{
    resource = resource + 1;
    notify();    //唤醒其他进程
}

int main()
{
    //不断的申请释放独占性资源
    while(1)
    {
        require();
        release();
    }
}

管理多个资源——会合:
还是拿读者写者说事儿,不过这一次,读者和写者都能享受到公平:

class ReaderWriter
{
    private:
        int readerCount = 0;     //省略了构造函数
        int writerCount = 0;
    public:
        void startRead();
        voidstartWrite();
        void finishRead();
        void finishWrite();
        int gerRC() { return readerCount; }
        int getWC() { return writerCount; }
        void incWC() { WriterCount = WriterCount + 1; }
}
void ReaderWriter::startRead()
{
    readerCount = readerCount + 1;
}

void ReaderWriter::finishRead()
{
    readerCount = readCount - 1;
}

void ReaderWriter::startWrite()
{
    while (readerCount > 0)
        this.finishRead();
}

void ReaderWriter::finishWrite()
{
    writerCount = writerCount - 1;
}
int main()
{
    ReaderWriter rw;
    while (1)
    {
        if (rw.getWC() = 0)
            rw.startRead();
        if (rw.getRC() > 0)
            rw.finishRead();
        if (rw.getWC() = 0)
        {
            rw.startWrite();
            rw.incWC();        //写者进入
        }
        if (rw.getWC() > 0)
            rw.finishWrite();
    }
}

小结


进程的同步主要就是解决对于一些共享型的变量操作问题,既要能使其他进程知道共享变量的改变,还要保证不能有多个进程对同一个共享变量操作,如此而已。

真的只是而已吗? (ŎдŎ;)

  • 13
    点赞
  • 61
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值