深入理解 go chan

您所在的位置:网站首页 golang多线程共享变量 深入理解 go chan

深入理解 go chan

2023-08-12 01:15| 来源: 网络整理| 查看: 265

go 里面,在实际程序运行的过程中,往往会有很多协程在执行,通过启动多个协程的方式,我们可以更高效地利用系统资源。 而不同协程之间往往需要进行通信,不同于以往多线程程序的那种通信方式,在 go 里面是通过 channel (也就是 chan 类型)来进行通信的, 实现的方式简单来说就是,一个协程往 channel 里面写数据,然后其他的协程可以从 channel 中将其读取出来。 (注意:文中的 chan 表示是 go 语言里面的 chan 关键字,而 channel 只是我们描述它的时候用的一个术语)

通道(chan)的模型

在开始讲 channel 之前,也许了解一下它要解决什么样的问题会比较好,所以先来聊聊一些背景知识。

关于通道,一个比较潦草的图大概是下面这个样子的:

chan1.png

在图中,协程 A 将消息 msg 写入到 channel 中,然后协程 B 从 channel 中读取消息,如果 B 没来得及从中读取消息,那么消息会在 chan 中存留。

这就是 go 的哲学:通过通信来实现共享内存。这不同于以往的多线程程序,在多线程程序中,往往是一块内存在不同线程之间进行共享, 然后通过一些保护机制,保证不允许多个线程同时对这块内存进行读写,比如通过 synchronized 关键字。 可能很多人都没有真正写过多线程的程序,但好像我们都有一种共识,多线程不安全。

多线程为什么不安全?

这是因为我们的程序除了通过共享一段内存之外,每一个 CPU 核心都有它本地的缓存,而 CPU 上的缓存是不共享的, 而线程可以同时在不同的 CPU 上执行。CPU 的执行过程是,先从内存中读取数据到 CPU 中,CPU 做完计算再更新到内存中。 这样一来,就有可能存在不同线程对同一段内存同时读写的问题。

这是什么问题呢?比如,A 线程计算完了但是还没有写回内存的时候,B 线程从内存读取出了 A 线程写入计算结果前的数据, 但是按我们的逻辑,B 应该是拿 A 线程的结算结果来进行逻辑运算的,这样就会出现数据不一致了,代码如下:

public class Main { int a = 0; public static void main(String[] args) throws InterruptedException { Main main = new Main(); main.run(); } // 将 a 加 1 private void add() { a++; } public void run() throws InterruptedException { // 启动两个线程来对 a 进行加 1 的操作 Thread t1 = new Thread(() -> { for (int i = 0; i < 10000; i++) { add(); } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 10000; i++) { add(); } }); // 启动线程 t1.start(); t2.start(); // 等待线程结束 t1.join(); t2.join(); // 我们的预期结果是 20000,但是实际运行显示了 14965 System.out.println(a); } } 复制代码

在上面的代码中,我们预期的运行结果是 20000 的,但是实际得到了 14965(实际上,每次执行结果都会不一样),这也就是我上面所说的问题, 其中有一个线程读取到了另一个线程的计算结果写入内存前的数据,也就是说,这个线程的计算结果被覆盖了, 因为线程将计算结果写回内存的时候是相互覆盖的。

所以我们可以回答刚才的问题了,多线程不安全是因为多个线程可以对同一段内存进行读写,这就存在其中一个线程还没来得及更新内存, 然后另一个线程读取到的数据是旧的。(也即数据竞争的问题)

具体可以看下图:

chan2.png

CPU 执行的时候,会需要将数据从内存读取到 CPU 中,计算完毕之后,再更新内存里面的数据。

错乱发生的过程大概如下:

CPU 1 先计算完了,计算的结果是 a = 3,但是还没来得及写入内存 CPU 2 也从内存里面获取 a 来进行计算,但是这个时候 a 还没有被 CPU 1 更新,所以 CPU 2 拿到的还是 2 CPU 2 进行计算的时候,CPU 1 将它的计算结果写入了内存,所以这个时候内存中的 a 是 3 CPU 2 计算完毕,将等于 2 的变量 a 加 1 得到结果 3 CPU 2 将结果 3 写入到内存,这个时候 a 的内存被更新,但是结果依然是 3 一种可行的办法 - 锁

其中一种可行的办法就是,给 add 方法加上 synchronized 关键字:

private synchronized void add() { a++; } 复制代码

这个时候,在我们的代码中,对 a 读写的代码都被 synchronized 保护起来了,在这段更新之后的代码中,我们得到了正确的结果 20000。

a++ 其实包含了读和写两个操作,程序运行的时候,会先将 a 读取出来,将其加上 1,然后写回到内存中。

synchronized 是同步锁,它修饰的方法不允许多个线程同时执行。synchronized 锁的粒度可大可小,粒度太大的话对性能影响也较大。

正如我们所看到的那样,synchronized 允许修饰一段代码,但是在实际中我们往往只是想保护其中某一个变量而已, 如果直接使用 synchronized 关键字来修饰一大段代码,那就意味着一个线程在执行这段代码的时候,其他线程就只能等待, 但是实际上,其中那些不涉及数据竞争的代码我们也无法执行,这样效率自然会降低,具体降低多少,取决于我们 synchronized 块的代码有多大。

go 中的处理办法

上面我们说到的多线程是通过共享内存来进行通信的,而在 go 里面,采用了 CSP(communicating sequential processes)并发模型, CSP 模型用于描述两个独立的并发实体通过共享 channel(管道)进行通信的并发模型。

CSP 是一套很复杂的东西,go 语言并没有完全实现它,仅仅是实现了 process 和 channel 这两个概念。process 就是 go 语言 中的 goroutine,每个 goroutine 之间是通过 channel 通讯来实现数据共享的。

然后我们上面说到,java 里面的 synchronized 关键字的粒度可能会比较大,这个是相比 go 里面的 channel 而言的, 在 go 里面,我们的代码在通信过程中很常见的一种阻塞场景是:

goroutine 需要从 channel 读取数据才能继续执行,但是 channel 里面还没数据,这个时候 goroutine 需要等待(会阻塞)另一个 goroutine 往 channel 写入数据。

对于这种场景,它隐含的逻辑是,阻塞的这个 goroutine 需要等待其他 goroutine 的结果才能继续往下执行,也就是 CSP 中的 sequential。下图是实际运行中的 chan:

chan10.png

我们上面的 chan 模型那个图,读和写都只有一个协程,但在实际中,读 chan 和写 chan 的协程都有一个队列来保存。 我们需要明确的一点事实是:队列中的协程会一个接一个执行,队列头的协程先执行,然后我们对 chan 的读写是按顺序来读写的,先取 chan 队列头的元素,然后下一个元素。

对应到上面 java 这个例子,我们在 go 里面可以怎么做呢?我们先把没有锁的 java 代码先写成 go 的代码:

package main import "fmt" var a = 0 func add(ch chan int, done chan


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3