Go 并发编程的思考( 二 )


当你启动Web浏览器的时候,必须有一些调用os进程操作的代码 。这意味着我们正在创建一个进程,一个进程可能会操作 os 为新选项卡创建另一个进程 。当浏览器选项卡打开并且您在执行日常工作的时候,该选项卡将开始为不容的活动(如页面滚动,下载,听音乐等)创建不同的线程,就像我们前面的两个进程处理任务图里看到的那样 。以下是 macOS 上Chrome浏览器应用程序任务图
该图显示了 google Chrome 浏览器对打开的标签页和内部服务使用的不同进程 。由于每个进程都至少又一个线程,因此我们可以看到线程数是大于进程数的 。

Go 并发编程的思考

文章插图
 
在多线程中,在一个进程中产生多个线程的情况下,具有内存泄漏的线程可能会耗尽其他现层需要的资源而导致进程无响应 。使用浏览器或其他任何程序的时候,你可能都遇到过出现无响应进程,任务管理器提示要将其杀死的现象 。
线程调度当多个线程串行或者并行运行的时候,由于多个线程之间可能共享一些数据,因此线程之间需要协同工作,以便于一次只有一个线程可以访问特定的数据,保证任务的安全执行 。我们把以某种顺序执行多个线程称为调度,操作系统线程由内核调度,某些线程由编程语言(如:Java的运行时环境-JRE )的运行时环境管理 。当多个线程试图同时访问同一数据导致数据被更改或导致意外结果时,我们就说发生了争用(race condition) 。
当我们设计并发的 Go 程序时,关键在于寻找到这种争用的情况,并且通过合理的措施才可以争用情况下,多线程程序的安全运行 。
Go 并发编程的思考

文章插图
 
在 Go 中使用并发接下来,我们来讨论如何在 Go 代码中实现并发 。我们知道,在 Java, C++ 之类具有面向对象编程(OOP)特性的的语言中一般具有一个线程类,我们可以通过该类在当前进程中创建多个线程对象 。由于 Go 语言没有传统 OOP 语法,因此它提供了 go 关键字来创建 goruntine 。当go关键字放在函数调用之前时,它将成为 goruntine 并被 go 调度执行 。
在后续的文章中,我们将单独讨论协程 goroutine (文中goroutine和协程是等价的概念),目前你可以将它看作是一个线程,从技术上来讲,协程的行为类似于线程,它是线程的抽象,下一小节将会介绍这两者之间的区别 。
当我们运行 Go 程序时,Go 运行时将在一个内核上创建一定数量的线程 。所有的 goruntine 在该内核上进行多路复用 。在任意时间点,一个线程执行一个 goroutine,如果该 goroutine 被停止,则它将被换成在该线程上执行另一个 goroutine 。这有点类似于内核的线程调度,但是由 Go 的运行时 (runtime) 处理,将比内核调度更快 。
建议在大多数的情况下,在一个内核上运行所有的 goroutine,但是如果你需要在系统的多核内核之前调度执行 goroutine,则可以使用 GOMAXPROCS 环境变量控制,也可以使用runtime.GOMAXPROCS(n)(
https://golang.org/pkg/runtime/#GOMAXPROCS) 调节运行时环境,其中 n 就是你要使用的核心数 。你可能会觉得将 GOMAXPROCS 设置成 1 使程序变慢 。不过这不是绝对的,如何设置这个参数取决于你目前运行程序的性质,很有可能花在多个核之间的通信开销要比你的运行开销还要大,这时候操作系统线程和进程将会遇到性能下降的情况,同样你的 Go 程序性能也就随之下降了 。Go 有一个 M:N 调度程序,它可以调度 Go 程序在多个处理器上执行 。任何时候,都需要在 GOMAXPROCS 个处理器上运行 N 个操作系统线程上再调度 M 个协程。在任何时候,每个内核最多运行一个线程,但如果需要,调度程序可以创建更多的线程,但是这种情况很少发生 。如果你的代码里面没有启动任何的 goroutine,那么无论你是用多少个内核,你的程序都只会在一个线程中、一个核上运行 。
线程 vs 协程由于线程和协程之间存在着明显的区别,下面我们将通过对比项来解释为什么线程开销比协程更高,以及为什么协程是我们应用程序实现高级别并发特性的关键所在 。
以上是几个重要的区别,推荐你去深入的研究 Go 并发模型的实现,它将会颠覆你对并发编程的理解 。为了突出这个 Go 协程模型的强大,我们可以来分析一个案例 。假设有一台 web 服务器,每分钟处理 1000 个请求 。如果必须同时运行每个请求,则意味着你需要创建 1000 个线程或将它们划分到不同的进程中 。这就是经典服务器 Apache (https://www.apache.org/) 的做法,如果每个线程消耗 1MB 的堆栈大小,则意味着你将要使用 1GB 的内存用于处理改流量 。当然,Apache 提供了ThreadStackSize 指令来管理每个线程的堆栈大小,但是问题仍然没有得到根本的解决 。对于 Go 写成来说,由于堆栈大小可以动态增长,因此,你可以毫无问题的生成 1000 个 goruntine。由于 goruntine 的初始堆栈空间可以调节,初始为8KB(更高的Go版本可能会更小),因此并不会消耗多大的内存空间 。同时当某个 goruntine 里面需要进行递归操作 。Go可以轻松的将堆栈大小调大,可以达到1GB的大小,这样无疑是“用更低的成本去做同样的事情” 。


推荐阅读