并发编程:聊聊常用的并发同步模板

10 篇文章 1 订阅
3 篇文章 0 订阅

并发(Concurrency)作为现代处理器的一大特征,已经不知不觉渗入到几乎所有开发人员的日常代码里,不管我们有多想避开它——大多数框架都对并发进行了封装,通常情况下我们都在写单线程代码。事实上,Java借由自己的内存模型(JMM, Java Memory Model),已经为并发准备了很完备的工具链条,是十分值得我们细细推敲和琢磨的。这篇文章也不打算深入并发的概念细节,只从近两天看到的一个题目说起,从最根本上说说我们的并发代码能怎么写。

顺序打印

1116. 打印零与奇偶数 来源:力扣(LeetCode)

现有函数 printNumber 可以用一个整数参数调用,并输出该整数到控制台。
例如,调用 printNumber(7) 将会输出 7 到控制台。
给你类 ZeroEvenOdd 的一个实例,该类中有三个函数:zero、even 和 odd 。ZeroEvenOdd 的相同实例将会传递给三个不同线程:

  • 线程 A:调用 zero() ,只输出 0
  • 线程 B:调用 even() ,只输出偶数
  • 线程 C:调用 odd() ,只输出奇数
  • 修改给出的类,以输出序列 “010203040506…” ,其中序列的长度必须为 2n 。

问题非常直观,一共三个线程,各自有各自需要打印的数字,竞争的资源都来自于ZeroEvenOdd的一个实例,所以本质上来说我们要对这个实例的方法访问实现同步与控制。

三种并发模式

不要被打印的值所迷惑了,这题的本质是实现对一个有限值域上的值的有序访问。一共有2n个值,假设从1开始,这2n个值的打印必须是按顺序、一个接一个的:

  • zero线程(线程A)访问1, 3, 5, 7, …
  • even线程(线程B)访问2, 6, 10, 14, …
  • odd线程(线程C)访问4, 8, 12, 16, …
  • 所有这些值里面,1访问后,2才可以访问;2访问后3才可以访问,依次类推。这也是并发控制的核心逻辑。

另外要说的是,题目里面特别说明了只有3个线程,其实这是一个非常重要、但也非常容易被忽视的前提条件,如果引入更多线程,这里给出的算法可能会出现问题。另外这题的三个线程会只会执行一次方法调用,比如A线程调用zero(),则期待你把所有的0都打印完成。根据上面所有的分析与背景,本文归纳了三种解决这个问题的方法,基本可以涵盖大多数并发问题可能涉及到的处理模式。

监视器锁与等待通知模型

顺序输出字符在单线程里从来都不是问题,你可以提前准备好所有这些字符,然后一次性输出。

	StringBuilder ans = new StringBuilder();
	for (int x = 1; x <= 2 * n; x++) {
		ans.append(x % 2 == 1 ? "0" : String.valueOf(x / 2));
	}

我们可以把上面的代码“移植”到并发环境中,可以发现单线程中的局部变量x变成了多线程的竞态条件——ABC三个线程都在争先恐后地修改它(这里主要是自增)。为了避免并发修改导致的值相互覆盖,我们可以通过加锁把并发下的修改变成一种顺序模型,即同一时间只有一个线程能够访问变量x

	int x = 1;
    public void zero(IntConsumer printNumber) throws InterruptedException {
        for (int i = 1; i <= n; i++) {
        	synchronized (this) {
            	while (x % 2 != 1) wait();
            	printNumber.accept(0);
            	x++;
            	notifyAll();
            }
        }
    }

上面就是一个典型的等待通知模型,注意这里使用了临界区。直接在方法上添加修饰符synchronized是不对的,第一个获取到锁的线程将可能永远一直拿着锁。因为这里成员变量x是一个共享资源,大家都有可能修改它,所以执行打印前需要检查一下x是不是处于我所想要的状态(对应while循环):

  • 如果x不是我想要的状态,则不能一直占有锁,所以我们直接调用wait()进入阻塞,并让出监视器锁
  • 如果x是我想要的状态,则我可以执行打印,并且修改x,最后我们需要调用notifyAll()通知对应监视器的等待队列里的所有其他线程,并退出临界区、让出监视器锁

等待通知模型(Wait and Notify Pattern)是使用监视器锁的常用范式,这里的notifyAll()会唤醒所有等待线程,并引发新一轮锁竞争,而监视器也提供一个notify()方法来让线程调度器唯一唤醒一个随机线程,但由于大多数情况下我们并不知道每个线程在等待什么条件,也就不知道该唤醒谁,所以直接唤醒所有线程可能更加明智。这里面必须使用while循环来反复检查自己的执行条件是否已经具备,相当多的情况下,一个线程被唤醒后可能发现并不具备执行条件,就应该再次进入阻塞让出锁。

如果我们用并发包里的重入锁(ReentranceLock)和对应的Condition,也能实现等待通知模型,非常类似就不再赘述了。这个场景下可能还是监视器锁更加简洁明了一些。

同步工具与生产消费模型

java.utils.concurrency包中为我们准备了大量更加灵活、更加方便的锁和同步工具,可以极大程度地方便我们进行并发控制。许多并发工具都是基于AbstractQueuedSynchronizer(AQS)实现的,其原理在源码阅读笔记:J.U.C CLH lock源码阅读笔记:AbstractQueuedSynchronizer
已经做过简单的分析。这里着重提一个特别经典和通用的模型——生产-消费模型,以及其经典伴随类Semaphore,也叫信号量,其实就是一个支持并发场景下安全增减的整形资源。我们先直接看看在这题下的用法:

	Semaphore semZero = new Semaphore(1);
	Semaphore semEven = new Semaphore(0);
	Semaphore semOdd = new Semaphore(0);
    public void zero(IntConsumer printNumber) throws InterruptedException {
        for (int i = 1; i <= n; i++) {
            semZero.acquire();
            printNumber.accept(0);
            if (i % 2 == 1) {
                semOdd.release();
            } else {
                semEven.release();
            }
        }
    }

这里的acquire()是一个阻塞操作,参数缺省时相当于acquire(1),并发包为我们隐藏了所有同步工具背后的等待、通知、排队等细节,作为使用者我们只需要知道,这里会一直等待直到取回想要数目的资源,相当于是一次消费;而随后在完成了所有操作后,该线程又作为一个生产者,把为对应的线程(B或C,取决于下一个将要打印的是奇数还是偶数)的资源release()出来。所以,我们用三个信号量实现了三个线程之间的通信与同步。从生产-消费模型的角度,这个问题一共存在两组生产者和消费者——线程A(zero)与线程B(even)、线程A与线程C(odd)分别互为生产者与消费者;A有两个生产者,所以A总能消费到两倍的资源、打印出两倍数量的0,并且同时生产的产品分给B与C一人一半。

类似的,也可以用各种阻塞队列来实现Semaphore一样的生产消费模型。

生产消费模型特别适合排队问题,即大家轮流做一些事情,即存在强次序或偏序,也适合天然的生产消费场景。只要善于发现并发场景下的生产消费关系,就可以适时地引入信号量,极大地简化我们的并发编程逻辑。比如我们想实现一个有界阻塞队列(Bounded Blocking Queue),这时候从C/P的角度,出队和入队(因为有界)应该分别有一个池子:

    private final int capacity;
    private final Semaphore sigDeque;
    private final Semaphore sigEnque;
    private final Queue<Integer> q;

    public BoundedBlockingQueue(int capacity) {
        this.capacity = capacity;
        this.sigDeque = new Semaphore(0);
        this.sigEnque = new Semaphore(capacity);
        this.q = new ArrayDeque<>();
    }
    
    public void enqueue(int element) throws InterruptedException {
        sigEnque.acquire();
        q.offer(element);
        sigDeque.release();        
    }

    public int dequeue() throws InterruptedException {
        sigDeque.acquire();
        int tmp = q.poll();
        sigEnque.release();
        return tmp;
    }

自旋无锁并发

最后一个,也是我个人认为并发问题中最为简练的并发模式,那就是忙循环(Busy-loop),或者有些地方也称为自旋(Spinning)。自旋在并发包里非常常见,是一种锁优化的方式,避免直接引用重量级的监视器锁,在并发包里非常常见,详见源码阅读笔记:J.U.C CLH lock。其核心就是线程不进入阻塞状态,而是不断检查自己的执行条件,直到满足条件为止。先直接看代码:

	int volatile x = 1;
    public void zero(IntConsumer printNumber) throws InterruptedException {
        for (int i = 1; i <= n; i++) {
            while (x % 2 == 0) Thread.yield();
            printNumber.accept(0);
            x++;
        }
    }

这段代码和监视器锁的方式结构非常相似,但是有两个重大差别:

  • 不使用synchronized(正确的废话),取而代之使用volatile去修饰共享变量,由此多个线程的写入的可见性将得以保证。其实,这两者都是JVM承诺的可见性保证的修饰符,详细参见读书笔记:从happens-before原则说起
  • Thread.yield()将提示JVM线程可以让出自己的时间片,理论上不是必须的。在自旋场景下,实际上线程是在浪费CPU的时间片。极端情况下,如果一个线程一直循环而不让出自己的时间片,那x将一直得不到修改,最后很多时间将被白白浪费。在Leetcode上,实测不添加Thread.yield(),大部分case都会超时。但是在实际生产环境中,线程应该不会直接被饿死,即x总是能得到修改,所以我看到大部分并发包里的自旋其实并没有显示使用Thread.yield()。所以使用自旋时,或许可以加上一个显示的声明,毕竟不让出CPU资源你也什么都不会做。

所有要点就这两个,是不是发现自旋的优雅之处了?所有线程只是在不断访问内存里的一个volatile变量,并尝试修改它,三个线程的通信通过一个简单的整形值,仅4个字节就实现了。而代码也极致简单,甚至不需要通知别的线程,你的通知在你完成x写入的那一刻,就已经完成了。

自旋无锁并发基本上将线程通信简化到了极致。代价是会浪费CPU的时间,所以很多时候在竞争激烈的场景,性能会有明显下降。我想这也是为什么监视器锁在后来的优化里特别在弱竞争的环境下使用自旋。

另外,还有一点特别重要。自旋常常会搭配CAS(Compare And Swap)来作为辅助。CAS操作是一个非常底层的机器指令,Java里的Atomic对其有相应的封装。由于原子操作和并发操作在一定程度上存在微妙的差异,又十分容易混淆,所以这里就不展开讨论了。自旋场景下的CAS,主要是帮助某些线程竞争到同样条件后,进入修改阶段,可能会有相互覆盖情况,原子操作因为从CPU角度是一个完整不可拆分的指令,所以可以避免这种相互覆盖的发生。在这个题目中,不存在这样的顾虑,所以并不需要使用AtomicInteger。如果我修改一些原题,改为A, B, C线程分别各有两个,那此时我们就必须使用AtomicInteger,因为volatile的自增并不是一个原子操作。大家不妨设想一下,如果两个A线程如果同时更新了x,究竟会打印几个0呢?

优缺点和局限性

等待通知模型最大的优点就是泛用性。基本上所有的并发问题,只要找到等待条件,总能得到相应的监视器锁的解法。底层的许多并发工具也会涉及到等待通知模型,但其缺点也恰恰来源于泛用性。可以认为它是线程锁定共享资源的通用形式,非常灵活,这导致为了进行相应的编程,通常需要站在线程的角度思考问题——该通知什么,什么时候要通知;该等待什么,什么时候该等待。当然,类似的思考模式下,我们也可以借用并发包来做一些编程上的简化。比如下面这题:1195. 交替打印字符串,与上面讲到的题很类似,只不过这题里的打印顺序更加混乱了:模3、模5、模3模5和其他数这四种情况是分别分配给4个线程。这题有一种使用CyclicBarrier的解法,我个人认为也是等待通知模型的一种具体实现:

	private static CyclicBarrier barrier = new CyclicBarrier(4);
    // printFizz.run() outputs "fizz".
    public void fizz(Runnable printFizz) throws Exception {
        for (int i = 1; i <= n; i++) {
            if (i % 3 == 0 && i % 5 != 0) {
                printFizz.run();
            }
            barrier.await();
        }
    }

这里,CyclicBarrier是把4个线程对每个数字的访问进行分组统筹,每个线程都会调用barrier.await()等待所有线程都完成这个数字的访问,才迭代到下一个数字,以此维持每个数字之间的整体访问次序。这样的等待模式如果要用wait()notify()实现则会显得很繁琐。而即便使用CyclicBarrier,整个解法读起来也会让人一时间怀疑其正确性,相对是比较晦涩的。

相比而言,生产消费模型就直观而清晰许多,几乎适用于所有强调次序的问题。比如1117. H2O 生成

    Semaphore smpOxygen = new Semaphore(0);
    Semaphore smpHydrogen = new Semaphore(2);

    public H2O() { }

    public void hydrogen(Runnable releaseHydrogen) throws InterruptedException {
		smpHydrogen.acquire();
        releaseHydrogen.run();
        smpOxygen.release();
    }

    public void oxygen(Runnable releaseOxygen) throws InterruptedException {
        smpOxygen.acquire(2);
		releaseOxygen.run();
        smpHydrogen.release(2);
    }

但同样的问题,如果用更基础的等待通知模型加相应的状态机,可以实现并发程度更加高的代码,注意这里面部分的状态转移存在竞争(※标识),需要加锁:

No.StateOxygenHydrogen
0{}2※1※
1{H}4※3※
2{O}WAIT4
3{H,H}0WAIT
4{H,O}WAIT0

再比如阻塞队列的实现,甚至让人觉得这个模型就是为阻塞队列而生的,它甚至能支持任意多的线程并发访问。但其局限性在于,有些生产消费模式并不是那么泛用,有些时候甚至有些反直觉。例如,还是上面这个交替打印字符串,其实整个逻辑背景和“打印零与奇偶数”几乎完全一样,但这个题的数字之间并没有什么明确的关联性,导致生产消费关系不是那么明确——例如,9和18都是属于Fizz线程的,但是下一个数分别是10和19,却是分别属于不同线程。尽管如此,通过稍微复杂一点的逻辑判断,我们其实还是能找到相应的生产消费关系。我们可以写一个函数专门判断接下来应该作为那个序列的生产者,但是代码会稍微长一点。更有甚者,像1226. 哲学家进餐就几乎不存在一个明确的生产消费关系。

此外,生产消费模式的一种特例可以将内存访问顺序化,也就是说同一时刻只有一个线程可以访问一片内存,具体做法就是将“访问”作为一种资源,并把这种资源的数量限制为1:

	private Semaphore accessControl = new Semaphore(1);
    public void foo() throws InterruptedException {
        accessControl.acquire();
        // do something
        accessControl.release();
    }

这样的模式下,foo的读写操作都相当于受到信号量保护,信号量就退化成一个类似于锁的东西。如果从信号量本身的逻辑上说,new Semaphore(1)就像是一个独占锁,而new Semaphore(10)就像是一把支持10个并发访问的共享锁。

最后一种自旋,它的局限性就更大了。因为整个操作序列都是无锁的,所以很多时候状态的写入都需要非常小心,尤其是状态一旦增多,没办法原子化了,那并发时整个操作可能都会出问题。所以对于自旋锁,最好只是在简单状态并发下使用(最好是单一一个状态,比如一个布尔值、一个整型值)。另外还要特别注意什么时候应该使用原子性。例如,回到打印题,因为打印和修改变量这两个操作并不是原子的,所以我们非常小心地在打印后才对,才对x进行修改,这样由于内存屏障的存在,x++之前的操作不会被排到x++之后:

	while (x % 2 == 0) Thread.yield();
    printNumber.accept(0);
    x++;

但如果是存在多个线程同时竞争x++这条指令,即便我们引入AtomicInteger,我们即便能确保只有一个线程打印成功,但是由于CAS会被迫前置,则打印的顺序仍然没法保证:

	int curr = 0;
	while ((curr = x.get() % 2) == 0) Thread.yield();
	if (x.compareAndSet(curr, curr + 1)) {
	    printNumber.accept(0);
	}

printNumber.accept(0)这条指令前,x已经更新了(尽管只有一个线程可以更新成功),则可能插入其他线程的打印逻辑。所以自旋锁应该在明确知道自己在做什么的时候使用,越简单的场景下使用越好。

总结

我们从一道简单的并发题出发,覆盖了并发场景下经常会遇到的三种现成的模式,这里面的每一种都值得我们细细去理解,并熟练掌握。对于并发包里给出的大量同步工具,不同工具会有自己的适用场景,而信号量(Semaphore)的逻辑及其简单明了,是一种必须掌握的工具。除了提到大量概念外,有很多的细节其实并未触及,比如notifyAll()之后,被唤醒的线程到底发生了什么?只有一个线程能竞争到锁,那其他的线程会怎么样?监视器锁的队列和AQS的队列有什么不同?原子性和并发究竟存在什么样微妙的关系?这些关键细节其实是深入到并发控制的核心层面,也是需要专门去掌握的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值