读书笔记:《七周七并发模型》

    科技2024-10-08  17

    概述

    并发和并行

    并发是同一时间应对多件事情的能力;并行是同一时间动手做多件事情的能力。

    并行架构

    位级并行:存在瓶颈,难以进入128位时代指令级并行:流水线,乱序执行,猜测执行数据级并行:GPU 图像处理任务级并行:多处理器 共享内存进行通信分布式内存,网络通信

    线程与锁模型(JVM:Java)

    线程与锁模型其实是对底层硬件运行过程的形式化。

    内置锁:内存和互斥模型

    互斥:使用锁保证某一时间仅有一个线程可以访问数据;会带来竞态条件和死锁。

    诡异的乱序执行:

    编译器的静态优化可以打乱代码的执行顺序JVM 的动态优化可以打乱代码的执行顺序硬件可以通过乱序执行来优化性能

    偶然发生的死锁

    一个线程使用了多把锁,就要考虑死锁发生的可能性。 简单的规则可以避开死锁:总是按照一个全局固定的顺序获取多把锁。(可以使用对象的散列值,但是散列值并不能保证唯一性) CopyOnWrite:就算只使用了一个锁,在调用未知的外部方法时,也有可能会引入额外的锁而产生死锁。 在调用外部方法时,可以考虑对外部对象实例进行复制。复制会产生额外的开销,但是也会减少外部锁冲突忙等待的开销。

    灵活运用多种外部锁

    自定义加锁和解锁可超时的锁 为了避免活锁,可以设置不同的超时时间 交替锁:按照一定的顺序逐步推进加锁的变量,一次只加锁一部分条件变量:条件变量与锁绑定,不满足条件时暂且解锁,满足条件时继续加锁运行。 需要在无限循环中判断条件变量await & signal ≠ wait & notify 原子变量volatile:保证变量的读写不被乱序执行 低级的同步,即将淘汰

    高级库使用时的注意事项

    线程池设置多大? CPU 密集型:线程池大小接近可用核数IO 密集型:线程池大小大于可用核数 多层循环时: 外层循环作为生产者,内层循环作为消费者,配合并发队列,使用生产者-消费者架构配合生产和消费的速度,创建多个实例,需要处理好读写的共享内存模型的并发机制共享内存还是存在加速瓶颈,可以使用 Map-Reduce 分布写并合并多个内存进一步加速分布内存的最大数量还是存在瓶颈 work-stealing: 多个独立任务创建多个任务队列分给多个线程执行使用双端队列,运行快的线程从其他任务队列尾部窃取任务并执行

    总结

    优点:适用面很广,近似于硬件工作方式,效率很高缺点:没有对并行的直接支持,在编程语言方面没有提供任何帮助

    多线程的难点不在于难以开发,而在于难以测试。同一问题难以复现,尽可能记录完善的日志,以提供信息。


    函数式编程模型(JVM:Clojure)

    函数式编程没有可变状态,所以不会遇到共享状态带来的种种问题。函数式编程是将计算过程抽象成表达式求值,由纯数学函数构成,很容易做到线程安全。

    Clojure 没有尾调用消除,因此递归调用会发生堆栈溢出。

    抛弃可变状态

    可变状态的风险:

    可变状态可能隐藏在外部类或外部方法中。可变状态可能会通过调用方法的返回值逃逸到没有加锁的外部。

    函数式并行

    fold(类似 Fork/Join)创建了一个并行任务的处理树,化简操作在树的叶节点进行,直到树的根节点。

    函数式并发

    描述函数式代码执行方式的一种方法就是不断用函数的执行结果替换函数的调用。与 Java 不同的是,函数式编程的每个函数都具有引用透明性,可以安全地调整函数的求值顺序。

    数据流式编程:future-promise(基于引用透明性)

    对 future 的解引用将被阻塞,直至其值被计算出来。调用 promise 的代码将不会执行,直到其值被赋值,并且对 promise 的解引用也会阻塞直至其值被计算出来。

    总结

    函数式编程最大的好处是我们可以确信程序是按照我们预想的方式运行的。比起等价的命令式程序,函数式程序会更简单,更容易推理,也更便于测试。由于函数式程序的引用透明性,且不使用可变状态,我们可以轻松地实现并行化,且并不存在线程与锁模型中的各种 bug。(Java 的 lambda 表达式和 Steam API)


    分离标识与状态(JVM:Clojure)

    混合函数式编程和可变状态,平衡两者的优点,成为并发编程的利器。

    原子变量与持久化数据结构

    原子变量是一个持久化数据结构,如果一个线程引用了持久化数据结构,那么其他线程对数据结构的修改对该线程就是不可见的。

    持久化数据并不是保存到磁盘或数据库中,而是数据被修改时总是保留其之前的版本。因为持久化数据结构分离了标识和状态,可以看作 CopyOnWrite。在命令式语言中,一个变量混合了标识和状态,一个标识对应一个状态,而状态随时间变化。持久化数据结构将标识和状态分离开,即如果获取了一个标识的当前状态,无论这个标识怎样改变,获取的状态将不会改变。

    代理和引用

    Clojure 中带!的函数不是事务安全的,

    原子变量:对一个值进行隔离的,同步的更新,即在swap!返回时完成更新,多个原子变量不能一致地更新代理:对一个值进行隔离的,异步的更新,即更新可能在send返回后进行,多个代理不能一致地更新引用:对多个值进行一致的,同步的更新,使用事务alter,具有原子性,一致性和隔离性,不具有持久性

    原子变量和 STM(软件事务内存)

    涉及多个值的一致性更新时:

    可以使用多个引用并通过事务来保证访问一致性。可以将多个值整个到一个数据结构中并使用原子变量来管理数据的访问一致性。

    总结

    Clojure 精心设计了用于并发的语义,从而保留了共享可变状态。

    优点:持久化数据结构将可变量的表示与状态分离开,解决了使用锁的方案的大部分缺点。缺点:不支持分布式编程。

    Actor 模型(Erlang:Elixir)

    Actor 模型保留了可变状态,只是不进行共享。Actor 对象是一个比线程还要轻量级的“进程”,通常不需要依赖线程池技术。

    消息和信箱

    异步地发送消息 消息发送到信箱,只需要关心发送消息时的并发问题,而不需要关心处理消息时的并发问题 函数是第一类对象 和数据相同:可以绑定到变量上,可以作为函数的参数

    错误处理和容错性

    将错误处理隔离到一个管理进程当中,进程的异常终止通过连接进行传播,将系统转化为系统进程后,可以捕获另一个进程的终止消息,而不是和其他进程一样一起异常终止。 当其他进程异常崩溃时,管理进程会重启其他进程。虽然会丢失一部分数据,但是至少还是可用的。

    如何保证消息必达:

    如果没有异常发生,消息一定能被送达并被处理。如果某个环节出现异常,异常一定会通知到使用者。(容错性的基石)

    任其崩溃的哲学:

    代码简洁且容易理解,可以清晰区分“一帆风顺”的代码和容错代码多个 Actor 独立,并不共享状态管理者可以选择是否处理崩溃

    分布式

    分布式管理库:OTP

    总结

    优点: 多个 Actor 不共享状态,单个 Actor 内为串行执行,所以针对并发只需要考虑多个 Actor 之间的消息流简洁的容错处理支持分布式内存模型 缺点: 也存在和线程与锁模型类似的死锁问题,但是更方便 debug独有问题:信箱溢出消息传递不适合细粒度的并行

    通信顺序进程模型(JVM:Clojure)

    Actor 模型的重点在于参与交流的实体;CSP 模型的重点在于用于交流的通道。 消息传递系统决定其特性和功能的首要因素并不是用于传递消息的代码或者消息的内容,而是消息的传输通道。

    channel 和 go 块

    Channel:一个 channel 就是一个线程安全的队列,任何任务只要持有 channel 的引用,就可以向一端添加消息,也可以从另一端删除消息。Actor 中的消息总是从一个 Actor 到另一个 Actor,而 channel 对于消息的发送者和接受者并不关心。channel 可以设置缓冲区的大小,并设置相应的拥塞处理策略(阻塞型,弃用新值型,移出旧值型)。Go 块:将阻塞操作变为暂停操作,并释放线程资源。

    多个 channel 与 IO

    多个 channel 读写和超时异步 IO

    客户端 CSP(ClojureScript)

    ClojureScript 可以编译成 JavaScript,这样客户端变成就可以在单线程 JavaScript 上运行协作式多任务,还能解决“回调困境”。

    将基于回调的代码转换成了基于 channel 的代码。

    总结

    优点:相比 Actor 模型,CSP 模型具有灵活性。缺点:CSP 模型的容错性和分布性不如 Actor 模型。

    Actor 模型侧重于容错性和分布式,而 CSP 模型侧重于效率和代码表达的流畅性。


    数据级并行模型(GPGPU:OpenCL)

    基于图形处理器的通用计算(General-Purpose computing on the GPU)

    GPGPU 编程

    流水线多 ALU

    OpenCL 程序:

    创建上下文,获取设备信息,创建命令队列,内核和命令队列都运行在上下文中编译内核,将内核编译成可以在设备上运行的代码创建缓冲区,内核使用的都是缓冲区中的数据,并将数据读入到输入缓冲区设置内核参数,执行工作项,让每个工作项都运行一次内核程序获取结果,并清理现场

    多维空间和工作组

    对于多维空间的并行计算,由于缓冲区是一维的,因此需要通过计算来确定数组的偏移。对于多个工作项的配合,使用同步屏障。一个工作组之间通过局部内存进行通信,利用同步屏障进行数据同步,保证一致性。

    OpenCL 也适用于 CPU:Intel 处理器的流式 SIMD 指令集和多核 CPU。

    OpenCL 和 OpenGL

    Java + LWJGL(LightWeight Java Graphics Library) 封装了 OpenCL 和 OpenGL。

    总结

    优点:适用于处理大量数据,尤其适合于科学计算、工程计算以及仿真领域。并且 GPU 的能耗指标低。缺点:不适用于解决非数值的问题。对于实现跨平台的调优很复杂。

    Lambda 架构模型(Java:Hadoop(HDFS & MapReduce))

    小规模应用数据并行技术。Lambda 架构既使用了可以进行大规模数据批处理的 MapReduce 技术,可使用了可以快速处理数据并及时反馈的流处理技术,这样的混搭能够为大数据问题提供扩展性、响应性和容错性都很优秀的解决方案。这里侧重于 Lambda 的并发和分布式特性。 Lambda 架构源自于它与函数式编程的相似性。从本质上来说,Lambda 架构是将计算函数施加于大量数据的一种通用方法。

    MapReduce

    将算法拆分成映射和化简两步的一个好处是易于并行化。

    词频统计:

    将输入分配给多个 mapper,每个 mapper 都会产生一些键值对。这些键值对会被发送给 reducer,产生最终的输出(通常也是一系列键值对)。每个 reducer 对应的键是不同的,因此具有相同键的键值对都会发送给同一个 reducer 进行处理。

    批处理层

    信息分为原始数据和(源于原始数据的)衍生信息。原始数据是永恒的真相,也是 lambda 架构的基础。

    原始数据是永远不变的,如果改变,可以添加时间戳。

    如果能够准确预测出未来会对原始数据进行怎样的查询,就可以预结算出一个批处理视图,这个视图包含这些查询将要返回的衍生信息,或者可以计算出这些衍生信息的数据。(例如:对历史数据生成报表和进行分析)

    批处理层不能单独构成端到端的应用,还需要服务层,对生成的批处理视图进行索引,这样就可以对索引进行查询了。(可以使用传统数据库来构建服务层)

    由于批处理层需要一定的时间来进行处理和更新,因此保存的并不是最新的数据。

    加速层

    加速层用于解决批处理层具有较高延迟的代价问题。当有新数据产生时,一方面将其添加到原始数据中,这样批处理层就可以进行处理;另一方面将其传给加速层。加速层会生成实时视图,实时视图会和批处理视图合并来满足对最新数据的查询。实时视图仅包含最后一次生成批处理视图后产生的原始数据所对应的衍生信息,当这部分数据被批处理层处理后,该实时视图将被弃用。

    加速层的过期和交替使用技术。通过维护两个加速层来达到更好的性能和可靠性。

    加速层的构建可以是同步的或异步的。

    Storm(Clojure)实现异步加速层。Hadoop 实现批处理,Storm 实现实时处理,方便地使用多台计算机进行分布式计算,以改善性能和容错性。

    总结

    优点:适用于处理大规模的数据,这些问题是传统数据处理架构难以应对的。缺点:数据达不到一定规模时,其成本将高于收益。

    Spark 可以使用很多比 MapReduce 更自然的算法(例如:图算法),批处理层和加速层也可以用 Spark 来实现。


    总结

    君欲何往(未来趋势)

    不变的状态(函数式编程)分布式

    未尽之路(其他技术)

    Fork/Join & Work-Stealing数据流:基于数据流的并行技术反映型编程:和消息传递有相似之处函数式反映型编程:通过对时间进行建模来扩展函数式编程网格计算:松耦合地建立分布式集群的方法元组空间:分布式联想记忆的一种形式,可用于实现进程间的通信
    Processed: 0.014, SQL: 8