王者并发课-黄金3:雨露均沾-不要让你的线程在竞争中被“饿死”

3,600 阅读6分钟

欢迎来到《王者并发课》,本文是该系列文章中的第13篇

在上篇文章中,我们介绍了避免死锁的几种策略。虽然死锁臭名昭著,然而在并发编程中,除了死锁之外,还有一些同样重要的线程活跃性问题值得关注。它们的知名度不高,但破坏性极强,本文将介绍的正是其中的线程饥饿活锁问题

一、饥饿的产生

所谓线程 饥饿(Starvation) 指的是在多线程的资源竞争中,存在贪婪的线程一直锁定资源不释放,其他的线程则始终处于等待状态,然而这个等待是没有结果的,它们会被活活地饿死

独占者的贪婪是饥饿产生的原因之一,概括来说,饥饿一般由下面三种原因导致:

(1)线程被无限阻塞

当获得锁的线程需要执行无限时间长的操作时(比如IO或者无限循环),那么后面的线程将会被无限阻塞,导致被饿死。

(2) 线程优先级降低没有获得CPU时间

当多个竞争的线程被设置优先级之后,优先级越高,线程被给予的CPU时间越多。在某些极端情况下,低优先级的线程可能永远无法被授予充足的CPU时间,从而导致被饿死。

(3) 线程永远在等待资源

在青铜系列文章中,我们说过notify在发送通知时,是无法唤醒指定线程的。当多个线程都处于wait时,那么部分线程可能始终无法被通知到,以至于挨饿。

二、饥饿与公平

为了直观体验线程的饥饿,我们创建了下面的代码。

创建哪吒、兰陵王等四个英雄玩家,他们以竞争的方式打野,杀死野怪可以获得经济收益。

public class StarvationExample {

  public static void main(String[] args) {
    final WildMonster wildMonster = new WildMonster();

    String[] players = {
      "哪吒",
      "兰陵王",
      "铠",
      "典韦"
    };
    for (String player: players) {
      Thread playerThread = new Thread(new Runnable() {
        public void run() {
          wildMonster.killWildMonster();
        }
      });
      playerThread.setName(player);
      playerThread.start();
    }
  }
}

 public class WildMonster {
   public synchronized void killWildMonster() {
     while (true) {
       String playerName = Thread.currentThread().getName();
       System.out.println(playerName + "斩获野怪!");
       try {
         Thread.sleep(500);
       } catch (InterruptedException e) {
         System.out.println("打野中断");
       }
     }
   }
 }

运行结果如下:

哪吒斩获野怪!
哪吒斩获野怪!
哪吒斩获野怪!
哪吒斩获野怪!
哪吒斩获野怪!
哪吒斩获野怪!
哪吒斩获野怪!
哪吒斩获野怪!
哪吒斩获野怪!
哪吒斩获野怪!
哪吒斩获野怪!

Process finished with exit code 130 (interrupted by signal 2: SIGINT)

从结果中可以看到,在几个线程的运行中,始终只有哪吒可以斩获野怪,其他英雄束手无策等着被饿死。为什么会发生这样的事?

仔细看WildMonster类中的代码,问题出在killWildMonster同步方法中。一旦某个英雄进入该方法后,将一直持有对象锁,其他线程被阻塞而无法再进入

当然,解决的方法也很简单,只要打破独占即可。比如,我们在下面的代码中把Thread.sleep改成wait,那么问题将迎刃而解。

 public static class WildMonster {
   public synchronized void killWildMonster() {
     while (true) {
       String playerName = Thread.currentThread().getName();
       System.out.println(playerName + "斩获野怪!");
       try {
         wait(500);
       } catch (InterruptedException e) {
         System.out.println("打野中断");
       }
     }
   }
 }

运行结果如下:

哪吒斩获野怪!
铠斩获野怪!
兰陵王斩获野怪!
典韦斩获野怪!
兰陵王斩获野怪!
典韦斩获野怪!

Process finished with exit code 130 (interrupted by signal 2: SIGINT)

从结果中可以看到,四个英雄都获得了打野的机会,在一定程度上实现了公平。(备注:wait会释放锁,但sleep不会,对此不理解的可以查看青铜系列文章。)

如何让线程之间公平竞争,是线程问题中的重要话题。虽然我们无法保证百分百的公平,但我们仍然要通过设计一定的数据结构和使用相应的工具类来增加线程之间的公平性。

关于线程之间的公平性,在本文中重要的是理解它的存在和重要性,关于如何优雅地解决,我们会在后续的文章中介绍相关的并发工具类

三、活锁的麻烦

相对于死锁,你可能对活锁没有那么熟悉。然而,活锁所造成的负面影响并不亚于死锁。在结果上,活锁和死锁都是灾难性的,都将会造成应用程序无法提供正常的服务能力

所谓活锁(LiveLock),指的是两个线程都忙于响应对方的请求,但却不干自己的事。它们不断地重复特定的代码,却一事无成

不同于死锁,活锁并不会造成线程进入阻塞状态,但它们会原地打转,所以在影响上和死锁相似,程序会进入无线死循环,无法继续进行。

如果你无法直观理解活锁是什么,相信你在走路时一定遇到过下面这种情况。两人相向而行,出于礼貌两人互相让行,让来让去,结果两人仍然无法通行。活锁,也是这个意思。

小结

以上就是关于线程饥饿与活锁的全部内容。在本文中,我们介绍了线程产生饥饿的原因。对待线程饥饿,没有百分百的方案,但可以尽可能地实现公平竞争。我们没有在本文列举线程公平性的一些工具类,因为我认为对问题的理解要比解决方案更重要。如果没有对问题的理解,方案在落地时也会出现知其然而不知其所以然的情况。另外,虽然活锁并不像死锁那样知名度,但是对活锁的恰当理解仍然非常必要,它是并发知识体系中的一部分。

正文到此结束,恭喜你又上了一颗星✨

夫子的试炼

  • 编写代码设置不同线程的优先级,体验线程饥饿并给出解决方案。

延伸阅读与参考资料

关于作者

从业近十年,先后从事敏捷与DevOps咨询、Tech Leader和管理等工作,对分布式高并发架构有丰富的实战经验。热衷于技术分享和特定领域书籍翻译,掘金小册《高并发秒杀的设计精要与实现》作者。


关注公众号【MetaThoughts】,及时获取文章更新和文稿。

如果本文对你有帮助,欢迎点赞关注监督,我们一起从青铜到王者