Thread和goroutine两种方式怎样实现共享变量按序输出

78次阅读
没有评论

共计 5569 个字符,预计需要花费 14 分钟才能阅读完成。

这期内容当中丸趣 TV 小编将会给大家带来有关 Thread 和 goroutine 两种方式怎样实现共享变量按序输出,文章内容丰富且以专业的角度为大家分析和叙述,阅读完这篇文章希望大家可以有所收获。

背景

最近在看 go 的一些底层实现,其中印象最为深刻的是 go 语言创造者之一 Rob Pike 说过的一句话,不要通过共享内存通信,而应该通过通信来共享内存,其中这后半句话对应的实现是通道(channel),利用通道在多个协程(goroutine)之间传递数据。看到这里,我不禁产生了一个疑问,对于无状态数据之间的传递,通过通道保证数据之间并发安全没什么问题,但我现在有一个临界区或者共享变量,存在多线程并发访问。Go 协程如何控制数据并发安全性?难道还有其它高招?带着这个疑问,我们看看 Go 是如何保证临界区共享变量并发访问问题。

下面我们通过一个经典的题目来验证线程和协程分别是如何解决的。

有三个线程 / 协程完成如下任务:1 线程 / 协程打印 1,2 线程 / 协程打印 2,3 线程 / 协程打印 3,依次交替打印 15 次。输出:123123123123123

 java 实现

java 对于这个问题如何解决呢?首先要求依次输出,那么只要保证线程互相等待或者说步调一致即可实现上述问题。

如何实现步调一致呢?我知道的方法至少有三种,以下我通过三种实现方式来介绍 Java 线程是如何控制临界区共享变量并发访问。

  Synchronized 实现  

通过 Synchronized 解决互斥问题;(wait/notifyAll)等待 - 通知机制控制多个线程之间执行节奏。实现方式如下:

public class Thread123 {

 public static void main(String[] args) throws InterruptedException {
 Thread123 testABC = new Thread123();

 Thread thread1 = new Thread(new Runnable() {
 @Override
 public void run() {
 try {
 for (int i = 0; i   5; i++) {
 testABC.printA();
 }
 } catch (InterruptedException e) {
 e.printStackTrace();
 }
 }
 });
 Thread thread2 = new Thread(new Runnable() {
 @Override
 public void run() {
 try {
 for (int i = 0; i   5; i++) {
 testABC.printB();
 }
 } catch (InterruptedException e) {
 e.printStackTrace();
 }
 }
 });
 Thread thread3 = new Thread(new Runnable() {
 @Override
 public void run() {
 try {
 for (int i = 0; i   5; i++) {
 testABC.printC();
 }
 } catch (InterruptedException e) {
 e.printStackTrace();
 }
 }
 });
 thread1.start();
 thread2.start();
 thread3.start();
 thread1.join();
 thread2.join();
 thread3.join();
 }
 int flag = 1;
 public synchronized void printA() throws InterruptedException {
 while (flag != 1) {
 this.wait();
 }
 System.out.print(flag);
 flag = 2;
 this.notifyAll();
 }

 private synchronized void printB() throws InterruptedException {
 while (flag != 2) {
 this.wait();
 }
 System.out.print(flag);
 flag = 3;
 this.notifyAll();
 }

 private synchronized void printC() throws InterruptedException {
 while (flag != 3) {
 this.wait();
 }
 System.out.print(flag);
 flag = 1;
 this.notifyAll();
 }
}

 

看到这段实现可能大家都会有如下两个疑问:

为啥要用 notifyAll,而没有使用 notify?


 

这两者其实是有一定区别的,notify 是随机的通知等待队列中的一个线程,而 notifyAll 是通知等待队列中所有的线程。可能我们第一感觉是即使使用了 notifyAll 也是只能有一个线程真正执行,但是在多线程编程中,所谓的感觉都蕴藏着风险,因为有些线程可能永远也不会被唤醒,这就导致即使满足条件也无法执行,所以除非你很清楚你的线程执行逻辑,一般情况下,不要使用 notify。有兴趣的话,上面例子,可以测试下,你就可以得知为什么不建议你用 notify。


 

为啥要用 while 循环,而不是用更轻量的 if?


 

利用 while 的原因,从根本上来说是 java 中的编程范式,只要涉及到 wait 等待,都需要用 while。原因是因为当 wait 返回时,有可能判断条件已经发生变化,所以需要重新检验条件是否满足。


 Lock 实现

通过 Lock 解决多线程之间互斥问题; (await/signal) 解决线程之间同步,当然这种实现方式和上一种效果是一样的。

public class Test {

 //  打印方式跟上一种方式一样,这里不在给出。
 private int flag = 1;
 private Lock lock = new ReentrantLock();
 private Condition condition1 = lock.newCondition();
 private Condition condition2 = lock.newCondition();
 private Condition condition3 = lock.newCondition();

 private void print1() {
 try {
 lock.lock();
 while (flag != 1) {
 try {
 this.condition1.await();
 } catch (InterruptedException e) {
 e.printStackTrace();
 }
 }
 System.out.print(A
 flag = 2;
 this.condition2.signal();
 }finally {
 lock.unlock();
 }

 }


 private void print2() {
 try {
 lock.lock();
 while (flag != 2) {
 try {
 this.condition2.await();
 } catch (InterruptedException e) {
 e.printStackTrace();
 }
 }
 System.out.print(B
 flag = 3;
 this.condition3.signal();
 }finally {
 lock.unlock();
 }

 }

 private void print3() {
 try {
 lock.lock();
 while (flag != 3) {
 try {
 this.condition3.await();
 } catch (InterruptedException e) {
 e.printStackTrace();
 }
 }
 System.out.print(C
 flag = 1;
 this.condition1.signal();
 }finally {
 lock.unlock();
 }
 }

 
 Semaphore 实现

信号量获取和归还机制来保证共享数据并发安全,以下为部分核心代码;

// 以 s1 开始的信号量, 初始信号量数量为 1 
private static Semaphore s1 = new Semaphore(1);
// s2、s3 信号量,s1 完成后开始, 初始信号数量为 0
private static Semaphore s2 = new Semaphore(0);
private static Semaphore s3 = new Semaphore(0);
static class Thread1 extends Thread {
     @Override
     public void run() {
        try {
           for (int i = 0; i i++) {
              s1.acquire();// s1 获取信号执行,s1 信号量减 1, 当 s1 为 0 时将无法继续获得该信号量
              System.out.print(1
              s2.release();// s2 释放信号,s2 信号量加 1(初始为 0),此时可以获取 B 信号量
            }
        } catch (InterruptedException e) {
           e.printStackTrace();
     }
  }
}

 

其实除了以上方法,用 CountDownLatch 实现多个线程互相等待应该也是可以解决的,这里不在过多举例。

 Go 实现

在用 Go 的实现过程中,主要用到了三个知识点。1、先后启用了三个 goroutine 对共享变量进行操作; 2、一把互斥锁产生的三个条件变量对三个协程进行控制; 3、使用 signChannel 目的是为了不让 goroutine 过早结束运行。

package main

import (
  log
  sync
)

func main() {
 // 声明共享变量
 var flag = 1
 // 声明互斥锁
 var lock sync.RWMutex
 // 三个条件变量,用于控制三个协程执行频率
 cnd1 := sync.NewCond(lock)
 cnd2 := sync.NewCond(lock)
 cnd3 := sync.NewCond(lock)
 // 创建一个通道,用于控制 goroutine 过早结束运行
 signChannel := make(chan struct{}, 3)
 // 最大循环次数
 max := 5

 go func(max int) {
 // 本次 goroutine 执行完成之后释放
 defer func() {
 signChannel  - struct{}{}
 }()
 // 循环执行
 for i := 1; i  = max; i++ {
 //  锁定本次临界环境变量修改
 lock.Lock()
 // 通过 for 循环检测条件是否发生变化,类似于上面的 while
 for flag != 1 {
 // 等待
 cnd1.Wait()
 }
 // 输出
 log.Print(flag)
 // 修改标识,释放锁、并对其它协程发送信号
 flag = 2
 lock.Unlock()
 cnd2.Signal()
 }
 }(max)

 go func(max int) {
 defer func() {
 signChannel  - struct{}{}
 }()
 for i := 1; i  = max; i++ {
 lock.Lock()
 for flag != 2 {
 cnd2.Wait()
 }
 log.Print(flag)
 flag = 3
 lock.Unlock()
 cnd3.Signal()
 }
 }(max)

 go func(max int) {
 defer func() {
 signChannel  - struct{}{}
 }()
 for i := 1; i  = max; i++ {
 lock.Lock()
 for flag != 3 {
 cnd3.Wait()
 }
 log.Print(flag)
 flag = 1
 lock.Unlock()
 cnd1.Signal()
 }
 }(max)

  - signChannel
  - signChannel
  - signChannel

}

 

可以看出这种实现方式也是通过锁和条件变量来控制临界区,这跟线程中 Lock、await/signal 实现方式没什么区别。(这是初次学习 Go 中互斥锁这块知识时,根据自己理解,编写的一种实现方式,如有问题,请多指教或者留言指正)

通过如上加锁和条件变量的机制解决了临界区变量并发安全问题,我们知道,之所以会如上出现并发问题,从源头上来说是硬件开发人员给软件开发人员挖的一个坑,为了提高并发性能,计算机出现了多核 CPU,为了提高运算速度,CPU 中又添加了高速缓存,这就导致多个 CPU 在做计算的时候缓存不能共享、交替执行,从而出现并发问题,无论线程、还是协程、解决思路很简单,通过加锁、禁用 CPU 缓存、公用内存。当然还存在编译优化带来的指令重排序问题,要想彻底解决必须从编程语言层面保证原子性、有序性。无论如何处理,要想保证临界区变量的安全,总会存在一定性能损耗。

上述就是丸趣 TV 小编为大家分享的 Thread 和 goroutine 两种方式怎样实现共享变量按序输出了,如果刚好有类似的疑惑,不妨参照上述分析进行理解。如果想知道更多相关知识,欢迎关注丸趣 TV 行业资讯频道。

正文完
 
丸趣
版权声明:本站原创文章,由 丸趣 2023-08-25发表,共计5569字。
转载说明:除特殊说明外本站除技术相关以外文章皆由网络搜集发布,转载请注明出处。
评论(没有评论)