This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
<audio id="audio" title="42 | Actor模型面向对象原生的并发模型" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/55/75/55cdd492a49d1fd9532e53aa1004fa75.mp3"></audio>
上学的时候,有门计算机专业课叫做面向对象编程,学这门课的时候有个问题困扰了我很久,按照面向对象编程的理论,对象之间通信需要依靠**消息**而实际上像C++、Java这些面向对象的语言对象之间通信依靠的是**对象方法**。对象方法和过程语言里的函数本质上没有区别有入参、有出参思维方式很相似使用起来都很简单。那面向对象理论里的消息是否就等价于面向对象语言里的对象方法呢很长一段时间里我都以为对象方法是面向对象理论中消息的一种实现直到接触到Actor模型才明白消息压根不是这个实现法。
## Hello Actor模型
Actor模型本质上是一种计算模型基本的计算单元称为Actor换言之**在Actor模型中所有的计算都是在Actor中执行的**。在面向对象编程里面一切都是对象在Actor模型里一切都是Actor并且Actor之间是完全隔离的不会共享任何变量。
当看到“不共享任何变量”的时候相信你一定会眼前一亮并发问题的根源就在于共享变量而Actor模型中Actor之间不共享变量那用Actor模型解决并发问题一定是相当顺手。的确是这样所以很多人就把Actor模型定义为一种**并发计算模型**。其实Actor模型早在1973年就被提出来了只是直到最近几年才被广泛关注一个主要原因就在于它是解决并发问题的利器而最近几年随着多核处理器的发展并发问题被推到了风口浪尖上。
但是Java语言本身并不支持Actor模型所以如果你想在Java语言里使用Actor模型就需要借助第三方类库目前能完备地支持Actor模型而且比较成熟的类库就是**Akka**了。在详细介绍Actor模型之前我们就先基于Akka写一个Hello World程序让你对Actor模型先有个感官的印象。
在下面的示例代码中我们首先创建了一个ActorSystemActor不能脱离ActorSystem存在之后创建了一个HelloActorAkka中创建Actor并不是new一个对象出来而是通过调用system.actorOf()方法创建的该方法返回的是ActorRef而不是HelloActor最后通过调用ActorRef的tell()方法给HelloActor发送了一条消息 “Actor” 。
```
//该Actor当收到消息message后
//会打印Hello message
static class HelloActor
extends UntypedActor {
@Override
public void onReceive(Object message) {
System.out.println(&quot;Hello &quot; + message);
}
}
public static void main(String[] args) {
//创建Actor系统
ActorSystem system = ActorSystem.create(&quot;HelloSystem&quot;);
//创建HelloActor
ActorRef helloActor =
system.actorOf(Props.create(HelloActor.class));
//发送消息给HelloActor
helloActor.tell(&quot;Actor&quot;, ActorRef.noSender());
}
```
通过这个例子你会发现Actor模型和面向对象编程契合度非常高完全可以用Actor类比面向对象编程里面的对象而且Actor之间的通信方式完美地遵守了消息机制而不是通过对象方法来实现对象之间的通信。那Actor中的消息机制和面向对象语言里的对象方法有什么区别呢
## 消息和对象方法的区别
在没有计算机的时代,异地的朋友往往是通过写信来交流感情的,但信件发出去之后,也许会在寄送过程中弄丢了,也有可能寄到后,对方一直没有时间写回信……这个时候都可以让邮局“背个锅”,不过无论如何,也不过是重写一封,生活继续。
Actor中的消息机制就可以类比这现实世界里的写信。Actor内部有一个邮箱Mailbox接收到的消息都是先放到邮箱里如果邮箱里有积压的消息那么新收到的消息就不会马上得到处理也正是因为Actor使用单线程处理消息所以不会出现并发问题。你可以把Actor内部的工作模式想象成只有一个消费者线程的生产者-消费者模式。
所以在Actor模型里发送消息仅仅是把消息发出去而已接收消息的Actor在接收到消息后也不一定会立即处理也就是说**Actor中的消息机制完全是异步的**。而**调用对象方法**,实际上是**同步**的对象方法return之前调用方会一直等待。
除此之外,**调用对象方法**,需要持有对象的引用,**所有的对象必须在同一个进程中**。而在Actor中发送消息类似于现实中的写信只需要知道对方的地址就可以**发送消息和接收消息的Actor可以不在一个进程中也可以不在同一台机器上**。因此Actor模型不但适用于并发计算还适用于分布式计算。
## Actor的规范化定义
通过上面的介绍相信你应该已经对Actor有一个感官印象了下面我们再来看看Actor规范化的定义是什么样的。Actor是一种基础的计算单元具体来讲包括三部分能力分别是
1. 处理能力,处理接收到的消息。
1. 存储能力Actor可以存储自己的内部状态并且内部状态在不同Actor之间是绝对隔离的。
1. 通信能力Actor可以和其他Actor之间通信。
当一个Actor接收的一条消息之后这个Actor可以做以下三件事
1. 创建更多的Actor
1. 发消息给其他Actor
1. 确定如何处理下一条消息。
其中前两条还是很好理解的就是最后一条该如何去理解呢前面我们说过Actor具备存储能力它有自己的内部状态所以你也可以把Actor看作一个状态机把Actor处理消息看作是触发状态机的状态变化而状态机的变化往往要基于上一个状态触发状态机发生变化的时刻上一个状态必须是确定的所以确定如何处理下一条消息本质上不过是改变内部状态。
在多线程里面由于可能存在竞态条件所以根据当前状态确定如何处理下一条消息还是有难度的需要使用各种同步工具但在Actor模型里由于是单线程处理所以就不存在竞态条件问题了。
## 用Actor实现累加器
支持并发的累加器可能是最简单并且有代表性的并发问题了可以基于互斥锁方案实现也可以基于原子类实现但今天我们要尝试用Actor来实现。
在下面的示例代码中CounterActor内部持有累计值counter当CounterActor接收到一个数值型的消息message时就将累计值counter += message但如果是其他类型的消息则打印当前累计值counter。在main()方法中我们启动了4个线程来执行累加操作。整个程序没有锁也没有CAS但是程序是线程安全的。
```
//累加器
static class CounterActor extends UntypedActor {
private int counter = 0;
@Override
public void onReceive(Object message){
//如果接收到的消息是数字类型,执行累加操作,
//否则打印counter的值
if (message instanceof Number) {
counter += ((Number) message).intValue();
} else {
System.out.println(counter);
}
}
}
public static void main(String[] args) throws InterruptedException {
//创建Actor系统
ActorSystem system = ActorSystem.create(&quot;HelloSystem&quot;);
//4个线程生产消息
ExecutorService es = Executors.newFixedThreadPool(4);
//创建CounterActor
ActorRef counterActor =
system.actorOf(Props.create(CounterActor.class));
//生产4*100000个消息
for (int i=0; i&lt;4; i++) {
es.execute(()-&gt;{
for (int j=0; j&lt;100000; j++) {
counterActor.tell(1, ActorRef.noSender());
}
});
}
//关闭线程池
es.shutdown();
//等待CounterActor处理完所有消息
Thread.sleep(1000);
//打印结果
counterActor.tell(&quot;&quot;, ActorRef.noSender());
//关闭Actor系统
system.shutdown();
}
```
## 总结
Actor模型是一种非常简单的计算模型其中Actor是最基本的计算单元Actor之间是通过消息进行通信。Actor与面向对象编程OOP中的对象匹配度非常高在面向对象编程里系统由类似于生物细胞那样的对象构成对象之间也是通过消息进行通信所以在面向对象语言里使用Actor模型基本上不会有违和感。
在Java领域除了可以使用Akka来支持Actor模型外还可以使用Vert.x不过相对来说Vert.x更像是Actor模型的隐式实现对应关系不像Akka那样明显不过本质上也是一种Actor模型。
Actor可以创建新的Actor这些Actor最终会呈现出一个树状结构非常像现实世界里的组织结构所以利用Actor模型来对程序进行建模和现实世界的匹配度非常高。Actor模型和现实世界一样都是异步模型理论上不保证消息百分百送达也不保证消息送达的顺序和发送的顺序是一致的甚至无法保证消息会被百分百处理。虽然实现Actor模型的厂商都在试图解决这些问题但遗憾的是解决得并不完美所以使用Actor模型也是有成本的。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,255 @@
<audio id="audio" title="43 | 软件事务内存:借鉴数据库的并发经验" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a3/ce/a3c1bfcb3946a823b59da193a830aece.mp3"></audio>
很多同学反馈说工作了挺长时间但是没有机会接触并发编程实际上我们天天都在写并发程序只不过并发相关的问题都被类似Tomcat这样的Web服务器以及MySQL这样的数据库解决了。尤其是数据库在解决并发问题方面可谓成绩斐然它的**事务机制非常简单易用**能甩Java里面的锁、原子类十条街。技术无边界很显然要借鉴一下。
其实很多编程语言都有从数据库的事务管理中获得灵感,并且总结出了一个新的并发解决方案:**软件事务内存Software Transactional Memory简称STM**。传统的数据库事务支持4个特性原子性Atomicity、一致性Consistency、隔离性Isolation和持久性Durability也就是大家常说的ACIDSTM由于不涉及到持久化所以只支持ACI。
STM的使用很简单下面我们以经典的转账操作为例看看用STM该如何实现。
## 用STM实现转账
我们曾经在[《05 | 一不小心就死锁了,怎么办?》](https://time.geekbang.org/column/article/85001)这篇文章中,讲到了并发转账的例子,示例代码如下。简单地使用 synchronized 将 transfer() 方法变成同步方法并不能解决并发问题,因为还存在死锁问题。
```
class UnsafeAccount {
//余额
private long balance;
//构造函数
public UnsafeAccount(long balance) {
this.balance = balance;
}
//转账
void transfer(UnsafeAccount target, long amt){
if (this.balance &gt; amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
```
该转账操作若使用数据库事务就会非常简单如下面的示例代码所示。如果所有SQL都正常执行则通过 commit() 方法提交事务如果SQL在执行过程中有异常则通过 rollback() 方法回滚事务。数据库保证在并发情况下不会有死锁而且还能保证前面我们说的原子性、一致性、隔离性和持久性也就是ACID。
```
Connection conn = null;
try{
//获取数据库连接
conn = DriverManager.getConnection();
//设置手动提交事务
conn.setAutoCommit(false);
//执行转账SQL
......
//提交事务
conn.commit();
} catch (Exception e) {
//出现异常回滚事务
conn.rollback();
}
```
那如果用STM又该如何实现呢Java语言并不支持STM不过可以借助第三方的类库来支持[Multiverse](https://github.com/pveentjer/Multiverse)就是个不错的选择。下面的示例代码就是借助Multiverse实现了线程安全的转账操作相比较上面线程不安全的UnsafeAccount其改动并不大仅仅是将余额的类型从 long 变成了 TxnLong ,将转账的操作放到了 atomic(()-&gt;{}) 中。
```
class Account{
//余额
private TxnLong balance;
//构造函数
public Account(long balance){
this.balance = StmUtils.newTxnLong(balance);
}
//转账
public void transfer(Account to, int amt){
//原子化操作
atomic(()-&gt;{
if (this.balance.get() &gt; amt) {
this.balance.decrement(amt);
to.balance.increment(amt);
}
});
}
}
```
一个关键的atomic()方法就把并发问题解决了,这个方案看上去比传统的方案的确简单了很多,那它是如何实现的呢?数据库事务发展了几十年了,目前被广泛使用的是**MVCC**全称是Multi-Version Concurrency Control也就是多版本并发控制。
MVCC可以简单地理解为数据库事务在开启的时候会给数据库打一个快照以后所有的读写都是基于这个快照的。当提交事务的时候如果所有读写过的数据在该事务执行期间没有发生过变化那么就可以提交如果发生了变化说明该事务和有其他事务读写的数据冲突了这个时候是不可以提交的。
为了记录数据是否发生了变化可以给每条数据增加一个版本号这样每次成功修改数据都会增加版本号的值。MVCC的工作原理和我们曾经在[《18 | StampedLock有没有比读写锁更快的锁](https://time.geekbang.org/column/article/89456)中提到的乐观锁非常相似。有不少STM的实现方案都是基于MVCC的例如知名的Clojure STM。
下面我们就用最简单的代码基于MVCC实现一个简版的STM这样你会对STM以及MVCC的工作原理有更深入的认识。
## 自己实现STM
我们首先要做的就是让Java中的对象有版本号在下面的示例代码中VersionedRef这个类的作用就是将对象value包装成带版本号的对象。按照MVCC理论数据的每一次修改都对应着一个唯一的版本号所以不存在仅仅改变value或者version的情况用不变性模式就可以很好地解决这个问题所以VersionedRef这个类被我们设计成了不可变的。
所有对数据的读写操作一定是在一个事务里面TxnRef这个类负责完成事务内的读写操作读写操作委托给了接口TxnTxn代表的是读写操作所在的当前事务 内部持有的curRef代表的是系统中的最新值。
```
//带版本号的对象引用
public final class VersionedRef&lt;T&gt; {
final T value;
final long version;
//构造方法
public VersionedRef(T value, long version) {
this.value = value;
this.version = version;
}
}
//支持事务的引用
public class TxnRef&lt;T&gt; {
//当前数据,带版本号
volatile VersionedRef curRef;
//构造方法
public TxnRef(T value) {
this.curRef = new VersionedRef(value, 0L);
}
//获取当前事务中的数据
public T getValue(Txn txn) {
return txn.get(this);
}
//在当前事务中设置数据
public void setValue(T value, Txn txn) {
txn.set(this, value);
}
}
```
STMTxn是Txn最关键的一个实现类事务内对于数据的读写都是通过它来完成的。STMTxn内部有两个MapinTxnMap用于保存当前事务中所有读写的数据的快照writeMap用于保存当前事务需要写入的数据。每个事务都有一个唯一的事务ID txnId这个txnId是全局递增的。
STMTxn有三个核心方法分别是读数据的get()方法、写数据的set()方法和提交事务的commit()方法。其中get()方法将要读取数据作为快照放入inTxnMap同时保证每次读取的数据都是一个版本。set()方法会将要写入的数据放入writeMap但如果写入的数据没被读取过也会将其放入 inTxnMap。
至于commit()方法我们为了简化实现使用了互斥锁所以事务的提交是串行的。commit()方法的实现很简单首先检查inTxnMap中的数据是否发生过变化如果没有发生变化那么就将writeMap中的数据写入这里的写入其实就是TxnRef内部持有的curRef如果发生过变化那么就不能将writeMap中的数据写入了。
```
//事务接口
public interface Txn {
&lt;T&gt; T get(TxnRef&lt;T&gt; ref);
&lt;T&gt; void set(TxnRef&lt;T&gt; ref, T value);
}
//STM事务实现类
public final class STMTxn implements Txn {
//事务ID生成器
private static AtomicLong txnSeq = new AtomicLong(0);
//当前事务所有的相关数据
private Map&lt;TxnRef, VersionedRef&gt; inTxnMap = new HashMap&lt;&gt;();
//当前事务所有需要修改的数据
private Map&lt;TxnRef, Object&gt; writeMap = new HashMap&lt;&gt;();
//当前事务ID
private long txnId;
//构造函数自动生成当前事务ID
STMTxn() {
txnId = txnSeq.incrementAndGet();
}
//获取当前事务中的数据
@Override
public &lt;T&gt; T get(TxnRef&lt;T&gt; ref) {
//将需要读取的数据加入inTxnMap
if (!inTxnMap.containsKey(ref)) {
inTxnMap.put(ref, ref.curRef);
}
return (T) inTxnMap.get(ref).value;
}
//在当前事务中修改数据
@Override
public &lt;T&gt; void set(TxnRef&lt;T&gt; ref, T value) {
//将需要修改的数据加入inTxnMap
if (!inTxnMap.containsKey(ref)) {
inTxnMap.put(ref, ref.curRef);
}
writeMap.put(ref, value);
}
//提交事务
boolean commit() {
synchronized (STM.commitLock) {
//是否校验通过
boolean isValid = true;
//校验所有读过的数据是否发生过变化
for(Map.Entry&lt;TxnRef, VersionedRef&gt; entry : inTxnMap.entrySet()){
VersionedRef curRef = entry.getKey().curRef;
VersionedRef readRef = entry.getValue();
//通过版本号来验证数据是否发生过变化
if (curRef.version != readRef.version) {
isValid = false;
break;
}
}
//如果校验通过,则所有更改生效
if (isValid) {
writeMap.forEach((k, v) -&gt; {
k.curRef = new VersionedRef(v, txnId);
});
}
return isValid;
}
}
```
下面我们来模拟实现Multiverse中的原子化操作atomic()。atomic()方法中使用了类似于CAS的操作如果事务提交失败那么就重新创建一个新的事务重新执行。
```
@FunctionalInterface
public interface TxnRunnable {
void run(Txn txn);
}
//STM
public final class STM {
//私有化构造方法
private STM() {
//提交数据需要用到的全局锁
static final Object commitLock = new Object();
//原子化提交方法
public static void atomic(TxnRunnable action) {
boolean committed = false;
//如果没有提交成功,则一直重试
while (!committed) {
//创建新的事务
STMTxn txn = new STMTxn();
//执行业务逻辑
action.run(txn);
//提交事务
committed = txn.commit();
}
}
}}
```
就这样我们自己实现了STM并完成了线程安全的转账操作使用方法和Multiverse差不多这里就不赘述了具体代码如下面所示。
```
class Account {
//余额
private TxnRef&lt;Integer&gt; balance;
//构造方法
public Account(int balance) {
this.balance = new TxnRef&lt;Integer&gt;(balance);
}
//转账操作
public void transfer(Account target, int amt){
STM.atomic((txn)-&gt;{
Integer from = balance.getValue(txn);
balance.setValue(from-amt, txn);
Integer to = target.balance.getValue(txn);
target.balance.setValue(to+amt, txn);
});
}
}
```
## 总结
STM借鉴的是数据库的经验数据库虽然复杂但仅仅存储数据而编程语言除了有共享变量之外还会执行各种I/O操作很显然I/O操作是很难支持回滚的。所以STM也不是万能的。目前支持STM的编程语言主要是函数式语言函数式语言里的数据天生具备不可变性利用这种不可变性实现STM相对来说更简单。
另外需要说明的是文中的“自己实现STM”部分我参考了[Software Transactional Memory in Scala](http://www.codecommit.com/blog/scala/software-transactional-memory-in-scala)这篇博文以及[一个GitHub项目](https://github.com/epam-mooc/stm-java)目前还很粗糙并不是一个完备的MVCC。如果你对这方面感兴趣可以参考[Improving the STM: Multi-Version Concurrency Control](http://www.codecommit.com/blog/scala/improving-the-stm-multi-version-concurrency-control) 这篇博文,里面讲到了如何优化,你可以尝试学习下。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,144 @@
<audio id="audio" title="44 | 协程:更轻量级的线程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ba/06/ba6e712ec378d7a8a18ccaa81a4f5206.mp3"></audio>
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 (
&quot;fmt&quot;
&quot;time&quot;
)
func hello(msg string) {
fmt.Println(&quot;Hello &quot; + msg)
}
func main() {
//在新的协程中执行hello方法
go hello(&quot;World&quot;)
fmt.Println(&quot;Run in main&quot;)
//等待100毫秒让协程执行结束
time.Sleep(100 * time.Millisecond)
}
```
我们在[《33 | Thread-Per-Message模式最简单实用的分工方法》](https://time.geekbang.org/column/article/95098)中介绍过利用协程能够很好地实现Thread-Per-Message模式。Thread-Per-Message模式非常简单其实越是简单的模式功能上就越稳定可理解性也越好。
下面的示例代码是用Golang实现的echo程序的服务端用的是Thread-Per-Message模式为每个成功建立连接的socket分配一个协程相比Java线程池的实现方案Golang中协程的方案更简单。
```
import (
&quot;log&quot;
&quot;net&quot;
)
func main() {
//监听本地9090端口
socket, err := net.Listen(&quot;tcp&quot;, &quot;127.0.0.1:9090&quot;)
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这三种基本的控制结构就是今天我们广泛使用的**顺序结构**、**选择结构**和**循环结构**。
<img src="https://static001.geekbang.org/resource/image/50/20/501db93634beff4776dd803eb4463920.png" alt="">
<img src="https://static001.geekbang.org/resource/image/15/bd/1528f3026981910eef7624aed9c72dbd.png" alt="">
<img src="https://static001.geekbang.org/resource/image/0d/fd/0d5fa62f62d1e999601ed7e2d52a1dfd.png" alt="">
<img src="https://static001.geekbang.org/resource/image/c7/9d/c7814d5d1563ab38c312b75c953c4c9d.png" alt="">
这三种基本的控制结构奠定了今天高级语言的基础,如果仔细观察这三种结构,你会发现它们的入口和出口只有一个,这意味它们是可组合的,而且组合起来一定是线性的,整体来看,代码的书写顺序和执行顺序也是一致的。
我们以前写的并发程序是否违背了结构化程序设计呢这个问题以前并没有被关注但是最近两年随着并发编程的快速发展已经开始有人关注了而且剑指Golang中的 go 语句,指其为“毒药”,类比的是 goto 语句。详情可以参考[相关的文章](https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/)。
Golang中的 go 语句不过是快速创建协程的方法而已这篇文章本质上并不仅仅在批判Golang中的 go 语句而是在批判开启新的线程或者协程异步执行这种粗糙的做法违背了结构化程序设计Java语言其实也在其列。
当开启一个新的线程时,程序会并行地出现两个分支,主线程一个分支,子线程一个分支,这两个分支很多情况下都是天各一方、永不相见。而结构化的程序,可以有分支,但是最终一定要汇聚,不能有多个出口,因为只有这样它们组合起来才是线性的。
## 总结
最近几年支持协程的开发语言越来越多了Java OpenSDK中Loom项目的目标就是支持协程相信不久的将来Java程序员也可以使用协程来解决并发问题了。
计算机里很多面向开发人员的技术,大多数都是在解决一个问题:**易用性**。协程作为一项并发编程技术,本质上也不过是解决并发工具的易用性问题而已。对于易用性,我觉得最重要的就是**要适应我们的思维模式**,在工作的前几年,我并没有怎么关注它,但是最近几年思维模式已成为我重点关注的对象。因为思维模式对工作的很多方面都会产生影响,例如质量。
一个软件产品是否能够活下去,从质量的角度看,最核心的就是代码写得好。那什么样的代码是好代码呢?我觉得,最根本的是可读性好。可读性好的代码,意味着大家都可以上手,而且上手后不会大动干戈。那如何让代码的可读性好呢?很简单,换位思考,用大众、普通的思维模式去写代码,而不是炫耀自己的各种设计能力。我觉得好的代码,就像人民的艺术一样,应该是为人民群众服务的,只有根植于广大群众之中,才有生命力。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。

View File

@@ -0,0 +1,133 @@
<audio id="audio" title="45 | CSP模型Golang的主力队员" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b7/d6/b7b0c4eb9cf2a5064c25fde0ac247ed6.mp3"></audio>
Golang是一门号称从语言层面支持并发的编程语言支持并发是Golang一个非常重要的特性。在上一篇文章[《44 | 协程:更轻量级的线程》](https://time.geekbang.org/column/article/99787)中我们介绍过Golang支持协程协程可以类比Java中的线程解决并发问题的难点就在于线程协程之间的协作。
那Golang是如何解决协作问题的呢
总的来说Golang提供了两种不同的方案一种方案支持协程之间以共享内存的方式通信Golang提供了管程和原子类来对协程进行同步控制这个方案与Java语言类似另一种方案支持协程之间以消息传递Message-Passing的方式通信本质上是要避免共享Golang的这个方案是基于**CSP**Communicating Sequential Processes模型实现的。Golang比较推荐的方案是后者。
## 什么是CSP模型
我们在[《42 | Actor模型面向对象原生的并发模型》](https://time.geekbang.org/column/article/98903)中介绍了Actor模型Actor模型中Actor之间就是不能共享内存的彼此之间通信只能依靠消息传递的方式。Golang实现的CSP模型和Actor模型看上去非常相似Golang程序员中有句格言“**不要以共享内存方式通信,要以通信方式共享内存**Dont 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 (
&quot;fmt&quot;
&quot;time&quot;
)
func main() {
// 变量声明
var result, i uint64
// 单个协程执行累加操作
start := time.Now()
for i = 1; i &lt;= 10000000000; i++ {
result += i
}
// 统计计算耗时
elapsed := time.Since(start)
fmt.Printf(&quot;执行消耗的时间为:&quot;, elapsed)
fmt.Println(&quot;, result:&quot;, result)
// 4个协程共同执行累加操作
start = time.Now()
ch1 := calc(1, 2500000000)
ch2 := calc(2500000001, 5000000000)
ch3 := calc(5000000001, 7500000000)
ch4 := calc(7500000001, 10000000000)
// 汇总4个协程的累加结果
result = &lt;-ch1 + &lt;-ch2 + &lt;-ch3 + &lt;-ch4
// 统计计算耗时
elapsed = time.Since(start)
fmt.Printf(&quot;执行消耗的时间为:&quot;, elapsed)
fmt.Println(&quot;, result:&quot;, result)
}
// 在协程中异步执行累加操作累加结果通过channel传递
func calc(from uint64, to uint64) &lt;-chan uint64 {
// channel用于协程间的通信
ch := make(chan uint64)
// 在协程中执行累加操作
go func() {
result := from
for i := from + 1; i &lt;= to; i++ {
result += i
}
// 将结果写入channel
ch &lt;- 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 &lt; 4; i++ {
go func() {
ch &lt;- 7
}()
}
// 创建4个协程作为消费者
for i := 0; i &lt; 4; i++ {
go func() {
o := &lt;-ch
fmt.Println(&quot;received:&quot;, o)
}()
}
```
Golang中的channel是语言层面支持的所以可以使用一个左向箭头&lt;-来完成向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中发送数据会导致发送消息的协程阻塞。
第三个区别则是关于消息送达的。在[《42 | Actor模型面向对象原生的并发模型》](https://time.geekbang.org/column/article/98903)这篇文章中我们介绍过Actor模型理论上不保证消息百分百送达而在Golang实现的**CSP模型中是能保证消息百分百送达的**。不过这种百分百送达也是有代价的,那就是有可能会导致**死锁**。
比如下面这段代码就存在死锁问题在主协程中我们创建了一个无缓冲的channel ch然后从ch中接收数据此时主协程阻塞main()方法中的主协程阻塞整个应用就阻塞了。这就是Golang中最简单的一种死锁。
```
func main() {
// 创建一个无缓冲的channel
ch := make(chan int)
// 主协程会阻塞在此处,发生死锁
&lt;- ch
}
```
## 总结
Golang中虽然也支持传统的共享内存的协程间通信方式但是推荐的还是使用CSP模型以通信的方式共享内存。
Golang中实现的CSP模型功能上还是很丰富的例如支持select语句select语句类似于网络编程里的多路复用函数select()只要有一个channel能够发送成功或者接收到数据就可以跳出阻塞状态。鉴于篇幅原因我就点到这里不详细介绍那么多了。
CSP模型是托尼·霍尔Tony Hoare在1978年提出的不过这个模型这些年一直都在发展其理论远比Golang的实现复杂得多如果你感兴趣可以参考霍尔写的[Communicating Sequential Processes](http://www.usingcsp.com/cspbook.pdf)这本电子书。另外霍尔在并发领域还有一项重要成就那就是提出了霍尔管程模型这个你应该很熟悉了Java领域解决并发问题的理论基础就是它。
Java领域可以借助第三方的类库[JCSP](https://www.cs.kent.ac.uk/projects/ofa/jcsp/)来支持CSP模型相比Golang的实现JCSP更接近理论模型如果你感兴趣可以下载学习。不过需要注意的是JCSP并没有经过广泛的生产环境检验所以并不建议你在生产环境中使用。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。