协程与Golang

Java 语言里解决并发问题靠的是多线程,但线程是个重量级的对象,不能频繁创建、销毁,而且线程切换的成本也很高,为了解决这些问题,Java SDK 提供了线程池。然而用好线程池并不容易,Java 围绕线程池提供了很多工具类,这些工具类学起来也不容易。那有没有更好的解决方案呢?Java 语言里目前可能有(Loom),在其他其他语言里,这个方案就是协程(Coroutine)。

可以把协程简单地理解为一种轻量级的线程。从操作系统的角度来看,线程是在内核态中调度的,而协程是在用户态调度的,所以相对于线程来说,协程切换的成本更低。协程虽然也有自己的栈,但是相比线程栈要小得多,典型的线程栈大小差不多有 1M,而协程栈的大小往往只有几 K 或者几十 K。所以,无论是从时间维度还是空间维度来看,协程都比线程轻量得多。

支持协程的语言还是挺多的,例如 Golang、Python、Lua、Kotlin 等都支持协程。下面就以 Golang 为代表,看看协程是如何在 Golang 中使用的。

Golang 中的协程

在 Golang 中创建协程非常简单,在下面的示例代码中,要让 hello() 方法在一个新的协程中执行,只需要go hello("World") 这一行代码就搞定了。你可以对比着想想在 Java 里是如何“辛勤”地创建线程和线程池的吧,我的感觉一直都是:每次写完 Golang 的代码,就再也不想写 Java 代码了。

import (
	"fmt"
	"time"
)
func hello(msg string) {
	fmt.Println("Hello " + msg)
}
func main() {
    // 在新的协程中执行 hello 方法
	go hello("World")
    fmt.Println("Run in main")
    // 等待 100 毫秒让协程执行结束
	time.Sleep(100 * time.Millisecond)
}

利用协程能够很好地实现 Thread-Per-Message 模式。Thread-Per-Message 模式非常简单,其实越是简单的模式,功能上就越稳定,可理解性也越好。

下面的示例代码是用 Golang 实现的 echo 程序的服务端,用的是 Thread-Per-Message 模式,为每个成功建立连接的 socket 分配一个协程,相比 Java 线程池的实现方案,Golang 中协程的方案更简单。

import (
	"log"
	"net"
)
 
func main() {
    // 监听本地 9090 端口
	socket, err := net.Listen("tcp", "127.0.0.1:9090")
	if err != nil {
		log.Panicln(err)
	}
	defer socket.Close()
	for {
        // 处理连接请求  
		conn, err := socket.Accept()
		if err != nil {
			log.Panicln(err)
		}
        // 处理已经成功建立连接的请求
		go handleRequest(conn)
	}
}
// 处理已经成功建立连接的请求
func handleRequest(conn net.Conn) {
	defer conn.Close()
	for {
		buf := make([]byte, 1024)
        // 读取请求数据
		size, err := conn.Read(buf)
		if err != nil {
			return
		}
        // 回写相应数据  
		conn.Write(buf[:size])
	}
}

利用协程实现同步

其实协程并不仅限于实现 Thread-Per-Message 模式,它还可以将异步模式转换为同步模式。异步编程虽然近几年取得了长足发展,但是异步的思维模式对于普通人来讲毕竟是有难度的,只有线性的思维模式才是适合所有人的。而线性的思维模式反映到编程世界,就是同步。

在 Java 里使用多线程并发地处理 I/O,基本上用的都是异步非阻塞模型,这种模型的异步主要是靠注册回调函数实现的,那能否都使用同步处理呢?显然是不能的。因为同步意味着等待,而线程等待,本质上就是一种严重的浪费。不过对于协程来说,等待的成本就没有那么高了,所以基于协程实现同步非阻塞是一个可行的方案。

OpenResty 里实现的 cosocket 就是一种同步非阻塞方案,借助 cosocket 可以用线性的思维模式来编写非阻塞的程序。下面的示例代码是用 cosocket 实现的 socket 程序的客户端,建立连接、发送请求、读取响应所有的操作都是同步的,由于 cosocket 本身是非阻塞的,所以这些操作虽然是同步的,但是并不会阻塞。

-- 创建 socket
local sock = ngx.socket.tcp()
-- 设置 socket 超时时间
sock:settimeouts(connect_timeout, send_timeout, read_timeout)
-- 连接到目标地址
local ok, err = sock:connect(host, port)
if not ok then
-  -- 省略异常处理
end
-- 发送请求
local bytes, err = sock:send(request_data)
if not bytes then
  -- 省略异常处理
end
-- 读取响应
local line, err = sock:receive()
if err then
  -- 省略异常处理
end
-- 关闭 socket
sock:close()   
-- 处理读取到的数据 line
handle(line)

结构化并发编程

Golang 中的 go 语句让协程用起来太简单了,但是这种简单也蕴藏着风险。要深入了解这个风险是什么,就需要先了解一下 goto 语句的前世今生。

学习编程语言时,很多书籍中都会谈到不建议使用 goto 语句,原因是 goto 语句会让程序变得混乱,当时对于这个问题我也没有多想,不建议用那就不用了。那为什么 goto 语句会让程序变得混乱呢?混乱具体指的又是什么呢?所谓的混乱指的是代码的书写顺序和执行顺序不一致。代码的书写顺序,代表的是思维过程,如果思维的过程与代码执行的顺序不一致,那就会干扰对代码的理解。思维是线性的,傻傻地一条道儿跑到黑,而 goto 语句太灵活,随时可以穿越时空,实在是太“混乱”了。

首先发现 goto 语句是“毒药”的人是著名的计算机科学家艾兹格·迪科斯彻(Edsger Dijkstra),同时他还提出了结构化程序设计。在结构化程序设计中,可以使用三种基本控制结构来代替 goto,这三种基本的控制结构就是今天广泛使用的顺序结构选择结构循环结构

这三种基本的控制结构奠定了今天高级语言的基础,如果仔细观察这三种结构,你会发现它们的入口和出口只有一个,这意味它们是可组合的,而且组合起来一定是线性的,整体来看,代码的书写顺序和执行顺序也是一致的。

以前写的并发程序,是否违背了结构化程序设计呢?这个问题以前并没有被关注,但是最近两年,随着并发编程的快速发展,已经开始有人关注了,而且剑指 Golang 中的 go 语句,指其为“毒药”,类比的是 goto 语句。详情可以参考相关的文章

Golang 中的 go 语句不过是快速创建协程的方法而已,这篇文章本质上并不仅仅在批判 Golang 中的 go 语句,而是在批判开启新的线程(或者协程)异步执行这种粗糙的做法,违背了结构化程序设计,Java 语言其实也在其列。

如何解决协作问题的呢?

当开启一个新的线程时,程序会并行地出现两个分支,主线程一个分支,子线程一个分支,这两个分支很多情况下都是天各一方、永不相见。而结构化的程序,可以有分支,但是最终一定要汇聚,不能有多个出口,因为只有这样它们组合起来才是线性的。

那 Golang 是如何解决协作问题的呢?

总的来说,Golang 提供了两种不同的方案:

一种方案支持协程之间以共享内存的方式通信,Golang 提供了管程和原子类来对协程进行同步控制,这个方案与 Java 语言类似;

另一种方案支持协程之间以消息传递(Message-Passing)的方式通信,本质上是要避免共享,Golang 的这个方案是基于CSP(Communicating Sequential Processes)模型实现的。

Golang 比较推荐的方案是后者。

什么是 CSP 模型

Actor 模型中 Actor 之间就是不能共享内存的,彼此之间通信只能依靠消息传递的方式。Golang 实现的 CSP 模型和 Actor 模型看上去非常相似,Golang 程序员中有句格言:“不要以共享内存方式通信,要以通信方式共享内存(Don’t communicate by sharing memory, share memory by communicating)。”虽然 Golang 中协程之间,也能够以共享内存的方式通信,但是并不推荐;而推荐的以通信的方式共享内存,实际上指的就是协程之间以消息传递方式来通信。

下面先结合一个简单的示例,看看 Golang 中协程之间是如何以消息传递的方式实现通信的。示例的目标是打印从 1 累加到 100 亿的结果,如果使用单个协程来计算,大概需要 4 秒多的时间。单个协程,只能用到 CPU 中的一个核,为了提高计算性能,可以用多个协程来并行计算,这样就能发挥多核的优势了。

在下面的示例代码中,用了 4 个子协程来并行执行,这 4 个子协程分别计算 [1, 25 亿]、(25 亿, 50 亿]、(50 亿, 75 亿]、(75 亿, 100 亿],最后再在主协程中汇总 4 个子协程的计算结果。主协程要汇总 4 个子协程的计算结果,势必要和 4 个子协程之间通信,Golang 中协程之间通信推荐的是使用 channel,channel 你可以形象地理解为现实世界里的管道。另外,calc() 方法的返回值是一个只能接收数据的 channel ch,它创建的子协程会把计算结果发送到这个 ch 中,而主协程也会将这个计算结果通过 ch 读取出来。

import (
	"fmt"
	"time"
)
 
func main() {
    // 变量声明
	var result, i uint64
    // 单个协程执行累加操作
	start := time.Now()
	for i = 1; i <= 10000000000; i++ {
		result += i
	}
	// 统计计算耗时
	elapsed := time.Since(start)
	fmt.Printf(" 执行消耗的时间为:", elapsed)
	fmt.Println(", result:", result)
 
    // 4 个协程共同执行累加操作
	start = time.Now()
	ch1 := calc(1, 2500000000)
	ch2 := calc(2500000001, 5000000000)
	ch3 := calc(5000000001, 7500000000)
	ch4 := calc(7500000001, 10000000000)
    // 汇总 4 个协程的累加结果
	result = <-ch1 + <-ch2 + <-ch3 + <-ch4
	// 统计计算耗时
	elapsed = time.Since(start)
	fmt.Printf(" 执行消耗的时间为:", elapsed)
	fmt.Println(", result:", result)
}
// 在协程中异步执行累加操作,累加结果通过 channel 传递
func calc(from uint64, to uint64) <-chan uint64 {
    // channel 用于协程间的通信
	ch := make(chan uint64)
    // 在协程中执行累加操作
	go func() {
		result := from
		for i := from + 1; i <= to; i++ {
			result += i
		}
        // 将结果写入 channel
		ch <- result
	}()
    // 返回结果是用于通信的 channel
	return ch
}

CSP 模型与生产者 - 消费者模式

可以简单地把 Golang 实现的 CSP 模型类比为生产者 - 消费者模式,而 channel 可以类比为生产者 - 消费者模式中的阻塞队列。不过,需要注意的是 Golang 中 channel 的容量可以是 0,容量为 0 的 channel 在 Golang 中被称为无缓冲的 channel,容量大于 0 的则被称为有缓冲的 channel

无缓冲的 channel 类似于 Java 中提供的 SynchronousQueue,主要用途是在两个协程之间做数据交换。比如上面累加器的示例代码中,calc() 方法内部创建的 channel 就是无缓冲的 channel。

而创建一个有缓冲的 channel 也很简单,在下面的示例代码中,创建了一个容量为 4 的 channel,同时创建了 4 个协程作为生产者、4 个协程作为消费者。

// 创建一个容量为 4 的 channel 
ch := make(chan int, 4)
// 创建 4 个协程,作为生产者
for i := 0; i < 4; i++ {
	go func() {
		ch <- 7
	}()
}
// 创建 4 个协程,作为消费者
for i := 0; i < 4; i++ {
    go func() {
    	o := <-ch
    	fmt.Println("received:", o)
    }()
}

Golang 中的 channel 是语言层面支持的,所以可以使用一个左向箭头(<-)来完成向 channel 发送数据和读取数据的任务,使用上还是比较简单的。Golang 中的 channel 是支持双向传输的,所谓双向传输,指的是一个协程既可以通过它发送数据,也可以通过它接收数据。

不仅如此,Golang 中还可以将一个双向的 channel 变成一个单向的 channel,在累加器的例子中,calc() 方法中创建了一个双向 channel,但是返回的就是一个只能接收数据的单向 channel,所以主协程中只能通过它接收数据,而不能通过它发送数据,如果试图通过它发送数据,编译器会提示错误。对比之下,双向变单向的功能,如果以 SDK 方式实现,还是很困难的。

CSP 模型与 Actor 模型的区别

同样是以消息传递的方式来避免共享,那 Golang 实现的 CSP 模型和 Actor 模型有什么区别呢?

第一个最明显的区别就是:Actor 模型中没有 channel。虽然 Actor 模型中的 mailbox 和 channel 非常像,看上去都像个 FIFO 队列,但是区别还是很大的。Actor 模型中的 mailbox 对于程序员来说是“透明”的,mailbox 明确归属于一个特定的 Actor,是 Actor 模型中的内部机制;而且 Actor 之间是可以直接通信的,不需要通信中介。但 CSP 模型中的 channel 就不一样了,它对于程序员来说是“可见”的,是通信的中介,传递的消息都是直接发送到 channel 中的。

第二个区别是:Actor 模型中发送消息是非阻塞的,而 CSP 模型中是阻塞的。Golang 实现的 CSP 模型,channel 是一个阻塞队列,当阻塞队列已满的时候,向 channel 中发送数据,会导致发送消息的协程阻塞。

第三个区别则是关于消息送达的: Actor 模型理论上不保证消息百分百送达,而在 Golang 实现的CSP 模型中,是能保证消息百分百送达的。不过这种百分百送达也是有代价的,那就是有可能会导致死锁

比如,下面这段代码就存在死锁问题,在主协程中,创建了一个无缓冲的 channel ch,然后从 ch 中接收数据,此时主协程阻塞,main() 方法中的主协程阻塞,整个应用就阻塞了。这就是 Golang 中最简单的一种死锁。

func main() {
    // 创建一个无缓冲的 channel  
    ch := make(chan int)
    // 主协程会阻塞在此处,发生死锁
    <- ch 
}

总结

Golang 中虽然也支持传统的共享内存的协程间通信方式,但是推荐的还是使用 CSP 模型,以通信的方式共享内存。

posted @ 2022-06-13 10:03  飞飞很要强  阅读(122)  评论(0编辑  收藏  举报