白驹过隙,这篇文章距今已有一年以上的历史。技术发展日新月异,文中的观点或代码很可能过时或失效,请自行甄别:)

和JS一样,Dart也是单线程模型,通过event queue在主线程中异步执行一些耗时操作, 总体来说和Javascript并无区别。那么,怎么在Dart中实现多线程呢?它的身上又有着什么不为人知的秘密呢?让我一一道来,揭开它神秘的面纱吧。

为何要多线程

人多好办事。事情多的时候,一个人再牛逼,也顶不住多个人一起办事的效率快。想一下小时候被老师罚抄作业100遍的事情,那个时候是不是就幻想过自己能够有100双手或者是100个自己来一起工作呢?又或者,想想你小时候正在大屁股电视上不亦乐乎玩着魂斗罗时,厨房的妈妈叫你马上到小卖部买一瓶酱油时那一脸不情愿的场景。是不是巴不得有一个小跟班能帮你办这件事情而你自己能够开开心心地继续玩游戏呢?

啊哈,这100个自己和小跟班,就可以理解为我们要说的“多线程”了。我们希望我们在做一些好玩而抽不开身的事情时,能够把其他一些事情交给这个小跟班做。

如何开启多线程

好了,上面说了原因。我们来看看在Dart里面如何拥有“小跟班”呢。在Golang中,一个go的关键字就可以轻松拥有一个小跟班。而在Javascript里面,我们通过new Worker()也能快速地拥有一个分身。同样在Dart里面,我们也可以轻松拥有小跟班。那就是isolate

好吧,人如其名,啊呸,名如其字。让我们先来用dart写一个你玩游戏时妈妈让你买酱油的例子吧。

# app.dart

import 'dart:async';
import 'dart:isolate';

void main() async {
  Home home = new Home();
  home.momIsCooking();
  home.iPlayGame();
  home.momLetMeBuySoySauce();
  home.iAgreeToBuy();

  ReceivePort receivePort = ReceivePort();
  Isolate.spawn(Home.buySoySauce, receivePort.sendPort);

  var saySauce = await receivePort.first;
  print(saySauce);
}

class Home {
  void momIsCooking() {
    print("mom is cooking");
  }

  void iPlayGame() {
    Timer.periodic(Duration(seconds: 1), (_) {
      print("game is funning");
    });
  }

  void momLetMeBuySoySauce() {
    print("sweet, can you help to buy a bottle of soy sause, please?");
  }

  void iAgreeToBuy() {
    print("ok,mom. I will buy it");
  }

  static buySoySauce(SendPort sendPort) {
    Timer(Duration(seconds: 3), () {
      sendPort.send("I have bought it");
    });
  }
}

此时我们运行这段代码,会发现输出如下:

mom is cooking
sweet, can you help to buy a bottle of soy sause, please?
ok,mom. I will buy it
game is funning
game is funning
game is funning
I have bought it
game is funning
game is funning

我们可以看到,当妈妈让我去买酱油时,我仍然沉迷于游戏中不可自拔。但是,你仍然会看到,酱油被买回来了。这一切的一切,都是我们的“分身isolate"替我们做的。而关键的代码就是这里了。

ReceivePort receivePort = ReceivePort();
Isolate.spawn(Home.buySoySauce, receivePort.sendPort);

var saySauce = await receivePort.first;

正是这3行,或者准确说,是第二代码让我开启了分身去买了酱油。也就是这个Isoalte.spawn。这一行代码便会开启一个isolate,通俗点来说就是,可以说是线程。而我们也可以看到,这个静态方法传入了两个参数。那么这两个参数是什么呢?

如果我们看Dart的文档,可以看到,第一个参数就是这个线程的entrypoint,而什么是entrypoint呢?不用太过纠结,可以把它理解为这个线程的入口函数即可。而这个入口函数的,接受一个参数。那么这个参数什么时候传递的呢?啊哈,想必你也看到了我们传递的第二个参数了吧。没错,我们给spawn传递的第二个参数就是其入口函数的参数啦。

另外,需要注意的是。这个入口函数,必须是顶层函数或者是一个静态的方法。并且,这个函数/方法内部也不能调用任何外部的变量。原因嘛,下面会说的。

OK,我们现在知道如何开始开启一个多线程了。在我们开始往下讲的时候,需要先说一下Dart下多线程的一些特性吧,毕竟,知己知彼,百战不殆嘛。

多线程的特性

很多语言中我们都会知道,多线程中各个资源都是独立的。同样的,在Dart的isolate里面也是如此。每个isolate都是完全独立的个体,完全不知道对方的存在,更别说对方的详情了。有一点像。。。现在的合租房。我想刚毕业的大学生应该深有体会吧。一个套三被隔断成套四,套五,甚至套六,下班回家都是关着门蜗居在那几平米的空间里各弄各的,唯一的接触可能是开门取快递时的那个照面。ok,说回正题吧。我们已经知道了dart下多线程的一个特性就是“互相独立”,或者叫做隔离。但是呢,如果你写java或者其他,都会知道,各个线程之间其实能够读取到主进程/主进程的一些共享变量。但是在Dart里面,这些也是完全不行的。也就是说,a,b,c这3个isolate里面,他们拥有的所有变量等,都不能互相直接读取,也没有所谓的共享变量。这是上面我说的入口函数不能读取外部变量的原因。

而这个特性呢,也是有好有坏。坏的是什么呢?由于完全没有共享变量这一说之后,对于一些公共的变量读取就会变得非常困难,得需要一些额外的方法和手段,无端增加了代码量。然而又正是因为其隔离型,也直接避免了读写锁之类的问题。所以,这个世界上没有绝对的坏,也没有绝对的好。看一个人的时候,你可能会觉得他是个好人,而有时候,又会发现这个人十恶不赦,他到底是好人还是坏人呢?

线程间通讯

上面说了,每个Isolate都是相互独立的。那么,各个线程又该如何通讯呢?毕竟,线程和人也是一样,是个社会性的东西。啊呸,人不是个东西。啊不,人是个东西,啊呸。。。算了不管了,继续说回isolate互相通讯的事儿吧。

还记得上个例子么,你如果仔细看console的输出的话,会发现有这么一句输出:

I have bought it

然后再看代码,你会发现,这段代码的输出实际上是在Isolate的入口函数,也就是buySoySauce里面。然而,我们的console又是在主线程也就是main里面。那么问题来了?这句话是如何让主线程知道的呢?yes,就是通过SendPort来得知的。

SendPort是个什么东西呢?可以把它认为是端口,管道,名字不重要,毕竟只是一个代码而已,我们也可以叫他阿猫阿狗,甚至给它称呼为女神的名字。不管叫什么,它都是拥有自己的一个特性,那就是,各个isolate之间传递数据,充当这信使的功能。有了它,刘英和永强才能够互诉心扉,倾诉爱意。一句简单的SendPort.send(msg)和一句简单的ReceivePort.listen就能打通异世界的大门。所以,线程间的通讯靠这玩意儿轻易之间就实现了。

等等,我们好像遗漏了什么。SendPort是怎么来的呢?看到上面的ReceivePort了么,我们通过这个声明取到的。而ReceivePort是啥?嗯,你可以把它当作是这个线程的邮局吧。SendPort就是邮差,如果你想取一下心爱的妹子给你发的邮件,可以通过ReceivePort.listen来监听。同样的,你如果想要给告诉女神你是多么爱她,让你这个邮局的邮差,也就是ReceivePort.sendPort来寄你的情书吧。SendPort.send一下就好了。

那么有了SendPort,是不是就如哆啦A梦的口袋一样是万能的了呢?No,很遗憾地告诉你,想多了。SendPort并不是万能药,这个邮差只能寄送一些范围之内的物品,对于一些违禁品,抱歉,它可是守法公民呢。既然我们要邮寄一些东西,我们来看看它能邮寄什么呢?

The content of message can be: primitive values (null, num, bool, double, String), instances of SendPort, and lists and maps whose elements are any of these. List and maps are also allowed to be cyclic.

看到没,如果是仅仅支持这些普通的数据结构。是不是顿时觉得很麻烦?是的,等等,对象也不能传递么?不,文档里面还有这么一段。

In the special circumstances when two isolates share the same code and are running in the same process (e.g. isolates created via Isolate.spawn), it is also possible to send object instances (which would be copied in the process).

松了一口气,哦,还是可以传递对象实例的啊,还好还好。不过根据我的实际用下来之后发现,这个对象实例也是有限制的。因为都是值传递,所以你很多引用型甚至复合型的字段仍然是无法传递的。比如File的实例对象。所以,你可以简单的理解为,SendPort能够传递的是这些null,number这些简单类型,如果是符合类型比如List,Map,他们的item也只能是前面说的简单类型的数据结构。不过其实也能够理解,毕竟。线程要独立,防止锁的存在,这么一想,也就释然了。

从生到死

万物皆逃不过生死轮回,不仅仅是人,isolate也是如此。既然isolate能够出生,那么肯定也有对这个世界说good bye的一天。那么,他们会以什么样的方式say goodbye呢?

1. 时辰已到,自然上路:

当耄耄之年,生命的蜡烛燃尽的时候,也是它逝去之时。对于isolate来说,他的任务完成之时,也是它逝去之日。还记得最开始那个买酱油的例子么?当它说出那句“I have bought it”的时候,它的使命也就完成了。在交出那瓶酱油的那一刻,转身离去,烟消云散,仿佛从未在这个世界走过一遭。然而那带体温的酱油瓶,又在无声的述说着它曾经来过的痕迹。

2. 天有不测风云,人有旦夕祸福,暴毙而亡:

曾看过报道,说这世界上每年大约有120多万人会死于车祸,算下来,每天差不多是3000多人。而这些人,可能还是咿呀学语的儿童,也可能是风华正茂,意气风发的少年,也可能是普普通通,平平淡淡的一个家庭主妇。无论他们是谁,他们都未曾过他们会以这样的意外离开这个人世。isolate也是如此,他们本来计划任务完成时荣归故里再静静死去,却不曾想被粗心的你一个bug于是exception致死。

是的,你感到很吃惊吧?什么?我只是简单的没有检测到一个变量的状态而已。我真的没想到会发生这样的事情,我不是故意的。你哭着对我说。你不是故意的,我知道。叹了口气,告诉了你如何急救的办法。

其实很简单,你只需要这么一笔就好了。

var thread = await Isolate.spwan(entryPoint,message);
thread.setErrorsFatal(false);

当它遭遇不测之时,记得打一下救护车,将它从死神手中抢夺过来即可。不用感谢我,我不是雷锋。只是不想看着一条鲜活的生命在我面前逝去而已。而我也想告诉你,多点try catch,不要闯红灯,酒后驾车。毕竟行车不规范,亲人两行泪。

3. 总有一些狠心的父母,会放弃自己的骨肉:

你以为这个世界很美好,所有的人都很善良,都很美好。但是我不得不很遗憾的告诉你,阳光再大,也有照不到的地方。你以为每一个父母都会疼爱自己的孩子,然后你却没有看到那些巨婴的父母多么的不负责。当他们觉得自己的孩子是个累赘时,会毫不犹豫地抛弃它。同样的,有些人看到别人家的猫猫狗狗那么可爱时,于是迫不及待,心血来潮的时候也养起了猫,喂起了狗,却发现没法忍受家里无处不在的猫毛,不能忍受每天5点就要开始狂吠不止让你带它出去遛弯的狗子,于是没多久,小区里又多了一只流浪的小东西只能无助地翻垃圾桶。

是的,你也可以随时杀掉你的孩子,你前一秒才刚刚召唤出来的”分身“,isolate。就这么一句就好了。

var thread = await Isolate.spwan(entryPoint,message);
thread.kill(priority:Isolate. immediate);

是不是觉得很简单?是的。但是,你真的下的了手么?就这么断送一个鲜活的生命。。。

最后

是的,isolate其实很简单,没有什么复杂。其实,这个世界又何尝不是呢?只是,我们又怎么变得那么复杂了呢?

真是个美丽的世界啊!

The end