• 那些烦人的同步和互斥问题


    真正的知识是深入浅出的,码农翻身” 公共号将苦涩难懂的计算机知识,用形象有趣的生活中实例呈现给我们,让我们更好地理解。

    本文源地址:那些烦人的同步和互斥问题

    1、批处理和脱机打印

    打印机程序,准确的说是打印机进程,在这个批处理系统中生活得非常自在,它所在的机器叫做IBM1401,除了打印之外什么也不干,每天大部分时间都是歇着。

    这个系统还有两台机器,一台还是IBM1401,它专门收集程序员写出来的穿孔卡片,然后转成磁带。  然后,操作员把磁带输入到IBM7094这个昂贵又强大的计算机上执行,执行结果也会输出到磁带上。 最后磁带被拿到1401上进行打印, 这叫做脱机打印(不和7094相连接)。如下图:

    图一、脱机打印

    在没有磁带来的时候,打印机程序无所事事,这就是脱机打印的好处。更大的好处是,磁带上需要打印的东西都是顺序的,一个接一个打印就可以了,完全没有冲突的问题。这是没办法的事情,那时候的计算机,尤其是IBM 7094太过昂贵,要充分的利用它的每一分每一秒,然后就想出了这样一个收集程序,然后成批处理的点子。 

    2、假脱机打印

    随着计算机系统的发展,打印程序的好日子很快就结束了,电脑越来越便宜,最后每个人的桌子上都有一台电脑了。个人电脑的计算能力更是强大的惊人,打印程序也被集成进了个人电脑里,和其他各种各样的程序生活在一起。打印的需求仍然很强烈,像Word、WPS、Excel 、 IE、Chrome...这些程序时不时都要打印,这时候冲突就会产生。 因为只有一个打印机,到底先打印谁的文档就是个大问题。最后操作系统老大想了个办法,专门开辟了一块空间,谁要想打印的话就按照先来后到的次序排队放在那,原来的打印进程变成了一个打印守护进程,会周期性的检查是否有文件打印,如果有则取出队伍排头的,打印出来,然后删除队列中文件。打印进程觉得这和原来的脱机打印很像,只不过用一个队列替换了原来的磁带,所以就叫做假脱机打印

    图二、假脱机打印

    3、冲突

    但是这个队列可不是原来的磁带了,它完全是个动态变化的东西,试运行还不到20秒,冲突就出现了。 

    WPS气冲冲的来着打印机进程:“打印机,你怎么搞的?我的放假通知.wps 为什么没有打印?” 

    打印机:“我没看到什么放假通知.wps啊!”

    WPS:“我明明放在了编号为3的槽里,怎么可能没有了?”

    在操作系统老大的协助下,大家查了半天,才知道是Word引起的:

    当时Word 插了一脚,也进来打印,读到了in = 3,就是说队列中编号为3的槽是空着的,他把3这个值放到了自己的局部变量free_slot中,这时候发生了一次时钟中断,操作系统老大认为Word已经运行了足够长的时间,决定切换到WPS进程。 

    WPS也读到了in = 3,把3 也存到自己的局部变量free_slot中,现在Word和WPS都认为下一个空的槽是3!

    WPS接着干活,他把文件放到了第3号槽里,并且把in 改为4,然后离开了。接下来又轮到Word运行了,它发现free_slot 为3,就把文件也放到了第3号槽里,把free_slot 加1,得到4,存入in 中。可怜的WPS,他的文件被覆盖掉了。

    但是打印机程序啥也察觉不出来,照样打印不误。  

    图三、冲突

    4、临界区

    很明显,Word和WPS 这两个进程甚至多个进程在读写in这个共享变量的时候,最后的结果严重依赖于进程运行的精确次序,这次是WPS的文件被覆盖掉了,下次可能就是Word了。 
    这种对共享变量,共享内存,共享资源进行访问的程序片段叫做临界区。代码在进入临界区之前一定要做好同步或者互斥的操作。
    WPS说:“老大,当时你切换Word的时候是不是发生了一次时钟中断 ?”
    操作系统:“是啊,有了时钟中断我才能计算时间,然后做进程切换啊!”
    “那在访问这个in共享变量的时候,我们自己能不能把这个中断给屏蔽?这样就不会有进程切换,肯定没问题了。” Word问道。
    “你想的美,时钟中断是最基本的东西,我把这个权限给了你们应用程序,到时候那个家伙屏蔽以后忘记开中断,我们整个系统就要完蛋了!” 操作系统狠狠的瞪了Word 一眼,Word赶紧噤声。 
    “不过我听说有些机器提供了一个特别的指令,这个指令能检查并且设置内存的值,而不会被打断,叫做TestAndSet,如果用C语言描述的话,类似这样:”

    bool TestAndSet(bool *lock){
      bool rv = *lock;
      *lock = true;
      return tv;      
    }

    “需要注意的是,这个函数中的三条指令是“原子”执行的,也就是说不会被打断。你们要是想用的话可以这样用:”

    bool lock = false;
    while(TestAndSet(&lock)){
      ;//什么也不做  
    }
    
    临界区;
    lock = false;
    剩余区;

    WPS说:“看起来有点复杂,让我想想啊,我和Word 的临界区代码,就是‘访问in变量,放入待打印文件,然后把in 加1’ 。那在进入临界区之前,我们俩都会调用TestAndSet。如果是我先调用,lock会被置为true,函数就会返回false。我就跳出了循环,可以进行后续临界区操作了,而Word 在调用 TestAndSet的时候,函数一直返回true,他只好不停的在这里循环了。”
    “是啊!”,Word 接着说,“我会不停的循环,直到WPS 离开临界区,然后把lock置为false。 ”
    “这个方法看起来很简单啊,只要一个变量加上一个函数就能让我和Word 进行互斥操作。”
    操作系统说: “是的,实现了你们两个的互斥,但是并不是所有的机器都会提供这样的指令,所以也不通用。”

    5、生产者-消费者

    打印机进程说:“你们讨论了半天,只是解决了两个进程往队列里放文件的冲突问题,现在也得考虑考虑我了。”
    “有你啥事?”,Word和WPS 都不以为然。
    “你们想想,那个打印队列对5个‘槽’,要是满了就没法往里边放了,你们都得等;要是空了,我就得等你们往里边放东西,所以咱们之间是不是也得同步?”
    “这就是所谓的生产者和消费者问题,也是个老大难问题了”,老大总结道。 
    “那用刚才那个锁好像不行啊,它能搞定互斥,但是做多个进程的同步就有点力不从心了。”
    操作系统老大说:“听说荷兰有个叫Dijkstra的,发明了一个信号量(semaphore)的东西,能解决这个问题。”

     

    图四、科学家Dijkstra.

    “信号量是什么鬼?信号灯吗? ”
    "所谓信号量,说白了其实就是一个整数,基于这个整数有两个操作:wait 和 signa。”

    int s;
    wait(s){
      while(s <= 0){
            ;//什么也不做
        }  
        s--;
    }        
    
    signal(s){
      s++;  
    }

    “这....这....这是啥玩意儿,这么简单,能解决啥问题?再说了你看看这s++、s--和我们队列中的in、out不是一样吗?在多进程切换下自身正确性都难保,还能解决别人的问题?” ,WPS吃惊的问。
    “WPS 问的好啊,说明他思考了,实际上这个东西必须得我出马来实现”,操作系统老大说,“ 我会在内核实现wait 和signal,让你们调用,比如我在做s++、s-- 时,我可以屏蔽中断。”
     Word说:“这个简单的小东西有点意思,比如我们俩可以用它做互斥:”

    int lock = 1;
    wait(lock);
    临界区;
    signal(lock);
    剩余区;

    打印进程说:“既然信号量是个整数,也许可以解决我们消费者-生产者直接的同步问题。”

    int lock = 1;
    int empty = 5;
    int full = 0;
    
    生产者:
    while(true){
      //如果empty的值小于等于0,生产者只好等待
      wait(empty);
      //加锁(因为要操作队列,和其它生产者互斥)
      wait(lock);
      把新产生的文件加入队列;
      //释放锁
      signal(lock);
      //通知消费者队列中已经产生了新的文件
      signal(full);     
    }
    
    消费者:
    while(true){
      wait(full);
      wait(lock);
      把队列头的文件打印,删除;
      signal(lock);
      signal(empty);
    }

    Word说:“我的天,真是复杂啊,容我想想,我和WPS都是生产者。假设我们俩都开始执行生产者代码,先去wait(empty),发现没有问题,因为empty的初始值为5。接下来都去执行wait(lock),这时候就看谁先抢到了。如果我先抢到,我就可以往队列里加文件,然后释放锁,WPS就可以接着放文件了。最后我还要把full这个值加一,目的是打印机进程可能在等待。恩,看起来不错!”
    操作系统老大说:“是啊!在多进程下,由于进程的执行随时都有可能被打断,还要保证正确性,不能出一点闪失。这对程序员的挑战很大,出现了疏漏,很难定位。”
    打印进程说:“老大,我注意到wait函数中,如果s 的值 为0或小于0 ,那个while 循环会一直执行,CPU岂不是一直在忙等?”
    “确实是这样,我们改进下,让忙等的进程进入休眠吧,很明显,这件事还得我做啊”, 操作系统说道。

    //把整数型信号量封装成一个结构体
    typedef struct{
      int value;
      struct process *list;//process进程就绪队列
    }semaphore;
    
    wait(semaphore *s){
      s->value--;
      if(s->value < 0){
        把当前进程加到s->list中;
        block();//把进程休眠,放弃cpu
      }
    }
    
    signal(semaphore *s){
      s->value++;
      if(s->value <= 0){
        从s->list中取出一个进程p
        wakup(p);//唤醒进程p
      }
    }

    WPS说:“唉,真是好复杂!不过我想起一个问题,这些wait、signal 能用到我们内部的线程的同步上吗?”
    “当然可以,概念上是一致的,都是访问共享资源的程序,需要做同步和互斥操作,可能表现形式不同。”
    “难道那些程序员们真的要使用这些wait、signal 编程吗?多容易出错啊!”
    “一般来说,程序员们所使用的工具和平台会做抽象和封装,例如在Java JDK中,已经对线程的同步做了封装了,对于生产者-消费者问题,可以直接使用BlockingQueue。非常简单,完全不用你去考虑这些wait、signal、full、empty。”

    //建立一个队列,其中队列中已经对wait和signal操作做了封装
    BlockingQueue queues = new LinkedBlockingQueue(10);
    
    生产者:
    //如果队列满,线程自动阻塞,直到有空闲位置
    queues.put(xxx);
    
    消费者:
    //如果队列空,线程自动阻塞,直到有数据到来
    queues.take();

    WPS说:“果然是抽象大法好,这多简单啊。”

    操作系统说:“是啊!无论是什么东西,抽象以后用起来好多了。但是还是要了解底层,这样出现了类似于BlockingQueue这样的新概念, 你能迅速搞明白。”

    (完)

    “码农翻身” 公共号:由工作15年的前IBM架构师创建,分享编程和职场的经验教训。

    长按二维码, 关注码农翻身

  • 相关阅读:
    并发队列 – 无界阻塞队列 LinkedBlockingQueue 原理探究
    并发队列 – 有界阻塞队列 ArrayBlockingQueue 原理探究
    Java回调机制解读
    一张图看懂encodeURI、encodeURIComponent、decodeURI、decodeURIComponent的区别
    uva 111 History Grading
    hdu 2546 饭卡
    hdu 2602 Bone Collector
    uva 10720 Graph Construction
    uva 10716 Evil Straw Warts Live
    uva 10070 Camel trading
  • 原文地址:https://www.cnblogs.com/tgycoder/p/6095825.html
Copyright © 2020-2023  润新知