协程
Hi,我是阿昌
,今天学习记录的是关于协程
的内容。
Java 语言里解决并发问题靠的是多线程
,但线程是个重量级
的对象,不能频繁创建、销毁,而且线程切换的成本也很高,为了解决这些问题,Java SDK 提供了线程池
。
然而用好线程池并不容易,Java 围绕线程池提供了很多工具类,这些工具类学起来也不容易。那有没有更好的解决方案呢?
Java 语言里目前还没有,但是其他语言里有,这个方案就是协程
(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 模式。
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 语言其实也在其列。
当开启一个新的线程时,程序会并行地出现两个分支,主线程一个分支,子线程一个分支,这两个分支很多情况下都是天各一方、永不相见。
而结构化的程序,可以有分支,但是最终一定要汇聚
,不能有多个出口,因为只有这样它们组合起来才是线性
的。
四、总结
最近几年支持协程的开发语言越来越多了,Java OpenSDK 中 Loom 项目的目标就是支持协程,相信不久的将来,Java 程序员也可以使用协程来解决并发问题了。
计算机里很多面向开发人员的技术,大多数都是在解决一个问题:易用性
。
协程 作为一项并发编程技术,本质上也不过是解决并发工具的易用性问题而已。
对于易用性,最重要的就是要适应我们的思维模式,在工作的前几年,并没有怎么关注它,但是最近几年思维模式已成为重点关注的对象。因为思维模式对工作的很多方面都会产生影响,例如质量
。
一个软件产品是否能够活下去,从质量的角度看,最核心的就是代码写得好。
那什么样的代码是好代码呢?最根本的是可读性好
。
可读性好的代码,意味着大家都可以上手,而且上手后不会大动干戈。
那如何让代码的可读性好呢?很简单,换位思考,用大众、普通的思维模式去写代码,而不是炫耀自己的各种设计能力。
好的代码,就像人民的艺术一样
,应该是为人民群众服务的,只有根植于广大群众之中,才有生命力。