跳转至

并发编程和并行编程

并发: 同一时刻只有单任务执行,通过时间片切换不同的任务。

并行: 多个任务在同一时刻执行,同时执行。

为什么并发程序这么难?

​ 拿我们从最开始梳理下程序的抽象。开始我们的程序是面向过程的,数据结构+func。后来有了面向对象,对象组合了数结构和func,我们想用模拟现实世界的方式,抽象出对象,有状态和行为。但无论是面向过程的func还是面向对象的func,本质上都是代码块的组织单元,本身并没有包含代码块的并发策略的定义。于是为了解决并发的需求,引入了Thread(线程)的概念。

线程(Thread)

  • CPU调度的最小单位
  • 同一进程下的多个线程可共享资源

线程的出现解决了两个问题,一个是GUI出现后急切需要并发机制来保证用户界面的响应。第二是互联网发展后带来的多用户问题。最早的CGI程序很简单,将通过脚本将原来单机版的程序包装在一个进程里,来一个用户就启动一个进程。但明显这样承载不了多少用户,并且如果进程间需要共享资源还得通过进程间的通信机制,线程的出现缓解了这个问题。

线程的使用比较简单,如果你觉得这块代码需要并发,就把它放在单独的线程里执行,由系统负责调度,具体什么时候使用线程,要用多少个线程,由调用方决定。但由此带来便利的同时也带来复杂度:

  • 竞态条件(race conditions) 如果每个任务都是独立的,不需要共享任何资源,那线程也就非常简单。但世界往往是复杂的,总有一些资源需要共享。
  • 依赖关系以及执行顺序 如果线程之间的任务有依赖关系,需要等待以及通知机制来进行协调。

为了解决上述问题,我们引入了许多复杂机制来保证。

系统里到底需要多少线程?

这个问题我们先从硬件资源入手,考虑下线程的成本:

  • 内存(线程的栈空间) 每个线程都需要一个栈(Stack)空间来保存挂起(suspending)时的状态。Java的栈空间(64位VM)默认是1024k,不算别的内存,只是栈空间,启动1024个线程就要1G内存。虽然可以用-Xss参数控制,但由于线程是本质上也是进程,系统假定是要长期运行的,栈空间太小会导致稍复杂的递归调用(比如复杂点的正则表达式匹配)导致栈溢出。所以调整参数治标不治本。
  • **调度成本(context-switch)**模拟两个线程互相唤醒轮流挂起,线程切换成本大约6000纳秒/次。这个还没考虑栈空间大小的影响。国外一篇论文专门分析线程切换的成本,基本上得出的结论是切换成本和栈空间使用大小直接相关。
  • CPU使用率 并发/并行最主要的一个目标就是我们有了多核,想提高CPU利用率,最大限度的压榨硬件资源,cpu能撑得住,那其他的资源呢?

Actor模型(并行编程)

并行的面向对象模式,比面向对象更加贴近现实。

面对对象编程对现实的抽象是对象=属性+行为(method),但当使用方调用对象行为(method)的时候,其实占用的是调用方的CPU时间片,是否并发也是由调用方决定的。

消息驱动:现实世界更像Actor的抽象,互相都是通过消息通信的。比如你对一个美女say hi,美女是否回应,如何回应是由美女自己决定的,运行在美女自己的大脑里,并不会占用发送者的大脑。

当消息发送出去之后,发送的消息和发送者**解耦**,其他的actor收到消息之后可以处理也可以发送给其他的actor。

异步: 拿邮件举例,每个actor都有一个专用的邮箱来接收消息,当一个actor实例向另外一个actor发消息的时候,并非直接调用actor的方法,而是把消息传递到对应的邮箱里,就好像邮递员,并不是把邮件直接送到收信人手里,而是放进每家的邮箱,这样邮递员就可以快速的进行下一项工作。所以在actor系统里,actor发送一条消息是非常快的。

隔离: 每个actor的实例都维护这自己的状态,与其他actor实例处于物理隔离状态,并非像 多线程+锁 模式那样基于共享数据。actor通过消息的模式与其他actor进行通信,与OO式的消息传递方式不同,actor之间消息的传递是真正物理上的消息传递。

天生分布式: 每个actor实例的位置透明,无论actor地址是在本地还是在远程机器上对于代码来说都是一样的。每个actor的实例非常小,最多几百字节,所以单机几十万的actor的实例很轻松。

容错: 传统的编程方式都是在将来可能出现异常的地方去捕获异常来保证系统的稳定性,系统可能还是会崩溃,这样就影响了整个系统,actor模式的话,每一个actor出现问题,不会影响到其他的actor。

更细节的说明在后面的skynet框架说明里。