《Clojure编程》笔记 第4章 多线程和并发
背景简述
本人是一个自学一年Java的小菜鸡,理论上跟大多数新手的水平差不多,但我入职的新公司是要求转Clojure语言的。坊间传闻:通常情况下,最好是有一定Java的开发工作经验,再转CLojure可能容易一些。我入职后的实际经历也确实让我感受到了Clojure的自学难度略大于自学Java,遇到的困难主要与中文资料较少有关,具体为:
1 中文的面向新手的较为系统的教程材料较少,目前个人感觉最好用的还是《CLojure编程 Emerick著》这本书,网上应该很好找,如果大家没有电子版的话可以留言,我看到后就立刻分享给大家
2 中文的网上相关问题和讨论较少, 以前学Java的时候基本遇到的问题用百度就能解决,现在大概率要直接用bing或谷歌,或者直接在stackoverflow(虽然是英文的,但貌似是最好用的IT问答网站)上查
我的这个系列笔记主要是基于 0工作经验的后端开发转学Clojure 的场景下完成的,里面有一些个人观点和个人理解的注释,写的时候是为了便于自己理解相关的概念,现在分享出来一方面是希望能帮助像我一样的新手更好地理解,另一方面也是希望有高手能够发现错误并帮忙斧正,谢谢
一些格式的简单约定:
粗体:比较重要的内容
斜体:我个人理解/观点或是补充内容,大家选择性食用
P15:表示书上第15页
第4章 多线程和并发
Clojure为了解决多线程带来的问题,提供的手段有:
- 减少程序中可变状态的使用,利用不可变的值、集合,以及他们所提供的值语义和高效的操作
举例:
(def map-test {:a "aa" :b "bb"})
=> #'helloworldclojure.core/map-test
map-test
=> {:a "aa", :b "bb"}
(assoc map-test :c "cc")
=> {:a "aa", :b "bb", :c "cc"}
map-test
=> {:a "aa", :b "bb"}
- 确实需要对状态进行修改,那么对可变的状态进行隔离,并且限制对这些可变值的修改方法
- 实在没有选择的情况下,可以用纯的锁、线程以及Java提供的高质量并发API
Clojure没有让并发编程变得非常简单,但是提供了一些新颖且久经考验工具,可以让并发编程更容易,写出来的东西更可靠
能够看出,Clojure的解决方案也是层层递进的关系
4.0 我的问题
Clojure提供了哪些Java中没有的东西
不可变的值、集合及对应的操作
CLojure提供的工具为什么新颖且久经考验工具,可以让并发编程更容易,写出来的东西更可靠
比如无需手动加锁自动确保高并发下数据一致性的ref(ref的alter commute还能分情况解决不同的问题)等
4.1 术语
实体:函数或者宏,可以认为是一些逻辑的封装
4.1.1 一个必须要先确定的思考基础
多线程的基础是一个cpu内的多线程进行计算,而不是分布式的多台服务器多个cpu进行计算,因此不要过分纠结于分布式情况下会出现的情况,本章内容是在单个cpu,多个线程的场景下完成的
4.2 计算在时间和空间内的转换
4.2.1 delay
主要作用及特点:
- 是一种让一些代码延迟执行的机制,代码只会在显式地调用deref的时候执行
- 对所包含的代码只执行一次,然后会把返回值缓存起来,之后再访问都是直接返回,可以理解为之后再执行的时候是没有副作用的,P160
补充:
- deref:解引用,一般使用语法糖@,解引用可以获取到引用的当前值,通常只有在高阶函数或者解引用时指定一个超时、超时特性的时候采用deref
- realized? : 检测delay的内容是否已经获取到了,比如(realized? (:content d))
4.2.2 future
主要作用及特点:
- 自动在另一个线程里面执行所包含的代码,然后通过解引用获取值,但如果future未完成就解引用,则会阻塞当前线程
- 缓存返回值,之后再解引用获得的是储存的返回值
- 可以在解引用的时候指定“超时时间”和“超时值”
P163 有和delay的对比案例,简单说就是多个调用者使用delay时,会导致当前线程阻塞(可以简单理解为串行),但是future则会新开一个线程,是通过异步执行的,阻塞时间缩短甚至没有阻塞时间,这个小改变就能大幅提升系统吞吐量
future和手动启动线程执行代码相比具备的优势:
- future和agent共享的是一个线程池的线程,因此共享资源就比自己独立创建更高效
- 使用future更加简洁
- future更容易和相关的Java API交互
4.2.3 promise
主要作用及特点:
- 类似于delay和future,可以被解引用,并且解引用的时候可以传入一个超时的参数,如果promise没执行好,那么代码会阻塞,直到值准备好
- 创建时不会指定任何代码或者函数来最终产生出它们的值,执行到某个点的时候,可以通过deliver函数填充一个值
详解见P164,简要说明就是一个一次性的单值管道,多个并发进程之间的显式的关系定义
4.3 简单地并行化
agent:一种并发组件,可以很高效地完成并行化计算任务
pmap:并行版的map,内部使用了future
补充:
dorun:彻底实例化惰性序列,P167
4.4 状态和标识
在clojure中,状态和标识有本质区别,即便在其他语言中这两个概念被当做同一个概念处理
状态:任何一个状态是不会发生改变的,状态可能随着时间的不同而不同,但是不同的两个时间下就是两种不同的状态
标识:标识是指表示一个东西的逻辑实体,比如Sarah,而不是她在某个特定时间点的状态,或者说标识在任何时间点都有一个特定的状态,而每个状态的变更都不会对已有的状态产生影响
标识可以理解为就只是一个名字而已,或者说某一时刻的名字而已,但是状态是内在属性的内容,CLojure中让标识的状态是不可变的,任何对标识的访问一定获得的是同样的内容,不同内容是因为已经是另一个标识了,或者说是另一个状态
4.5 Clojure的引用类型
4种引用类型以及它们的语义使得我们可以设计并发程序,并让这个并发程序最大限度地利用已有硬件的最大计算能力,同时能避免使用线程和锁可能会带来的一系列bug
四种引用类型的共通点:
- 引用类型都是包含其他值的容器,容器里面的值可以利用某些函数进行修改
- 访问值的语法都是通过解引用deref或@,返回的事引用的状态的一个快照,这个快照是不可变的值,但是引用的状态是有可能在之后发生变化的
- 解引用是绝对不会造成阻塞的,解引用不会和其他操作相互干扰,这个和delay promise future形成鲜明对比,后者是有可能阻塞的
4.6 并发操作的分类
对并发操作分类的概念,能够帮助我们更清楚地理解每种类型最合适在什么样的场合下使用
4.6.1 协调
一个“协调”的操作是为了产生正确的结果,这个操作里面的多个角色必须相互合作(或者至少不要相互干扰),典型例子就是银行转账。
4.6.2 同步
“同步”是指线程会等待、阻塞或者睡眠,知道它获得对于指定上下文的独占访问,而“异步”操作是指调用线程不用阻塞在一个调用上面,它可以继续去做别的事情
4.6.3 选用引用类型的标准
当为某个问题选择引用类型的时候,对照这个分类即可
协调且异步的组合通常在分布式系统里面更加常见,比如最终一致性的数据库只保证一段时间之后,所有的对于状态的修改会合并到最终状态中去。
4.7 原子类型(Atom)
最基本的引用类型,是实现同步的、无需协调的、原子的“先比较再设值”的修改策略,每一个操作都是自动完全隔离的,没办法协调,也一定是阻塞等待的
函数方法:
swap!(格式:swap! 要修改的原子类型 函数 一些参数)
compare-and-set!
reset!
使用swap! 对原子类型进行修改,通过P175的例子可以看出,swap!的重试是指重新获取新值,进行比较,与旧值匹配之后才会完成修改
使用compare-and-set!是传入一个比较的值,如果修改成功就返回true,否则返回false
使用reset!就是不管现在的值究竟是什么,就是要重新设值
4.8 通知和约束
引用类型除了可以通过deref获取当前值之外,还有两个共同的特性:分别让我们对引用的状态进行监控,以及对新的状态的合法性进行校验,分别以观察器和校验器的形式提供
4.8.1 观察器
观察器是在引用的状态发生改变的时候会被调用的函数,Clojure的观察器要比设计模式的观察者更加通用,因为Clojure的观察器只是一个函数而已,而且通知的机制也是现成的,不需要自己实现。
一般来说,观察者函数使得改变可以即时通知到其他引用或者系统是非常方便的
一个观察器必须接受4个参数:key 发生改变的引用(四个引用类型之一) 引用的旧状态 现在的新状态
add-watch是用来增加一个观察器给引用,remove-watch是用来移除一个观察器的
identify函数直接返回的是传入的参数,P178
4.8.2 校验器
校验器可以以任何想要的方式对引用的状态进行控制,简单理解就是对引用进行状态修改,需要经过校验器的判断,校验器同意才能完成状态修改,校验器必须返回true才能完成修改
是通过:validator关键字指定的,只有atom ref agent类型可以在创建的时候通过:validator直接指定一个校验器,如果要修改校验器,可以使用set-validator!函数
4.9 ref
ref可以保证多个线程可以交互地对这个ref进行操作:
- ref会一直保持一个一致的状态,不会出现外界可见的不一致状态
- 多个ref之间不可能产生竞争条件
- 不需要手动使用锁
- 不可能出现死锁
4.9.1 软件事务内存
任何协调多个线程,对共享存储进行修改的方式,都可以称为STM,就像gc之于手动管理内存,STM就是对手动锁管理的简化
通常情况下,使用经过证明的、自动化手段将开发人员从底层细节中解放出来,效果甚至比底层领域专业人员手写的代码效果要好
CLojure的STM使用的就是数据库中的多版本并发控制(MVCC),对一系列ref的每个修改都是具有事务语义的,符合ACI,不符合D单纯是因为STM是纯内存实现的
when-let在本地绑定为空的时候是不会执行的,P183
4.9.2 对ref进行修改的机制
dosync:开启Clojure的STM和事务
alter commute ref-set:多个事务试图对一个或多个ref同时进行有冲突的修改,冲突与否是由这三个对ref进行修改的函数所决定的
4.9.2.1 alter
alter的语义为:当一个事务要提交的时候,ref的全局的值必须和这个事务内第一次调用alter的时候一样,否则事务会被重启,从头再执行一遍
这张图里面的结果就是,虽然t1开始的早,但是t1提交时,a的值已经被t2改变了,所以事务重启,从头重新执行一遍
4.9.2.2 commute
alter可以认为是对ref状态最安全的修改方式,不过没有对修改ref的可重排序进行任何假设,一些情况下,是可以对ref的修改操作进行重排序的,这时可以用commute代替alter
commute没有什么神奇的,单纯只是计算了两次,第一次是事务内正常计算,第二次是提交的时候利用ref的最新全局值重新计算一遍,commute的使用要非常谨慎,最简单的判断方式还是操作重排是否会对结果产生影响
用了重排序,是可以减少潜在的冲突几率和重试次数的,从而大幅提升吞吐量
我个人的理解是,重点关注commute的两次计算特性。第一次计算的时候是会获取一次全局最新值,只是应付一波计算,但第二次计算才是真正获取全局最新值并提交修改,相较于alter每次获取都很较真的对比,commute的第一次获取(事务内计算)并不会很较真,且第二次获取(提交时获取)也不会将全部事务推倒重来,而是只搞定自己的事情,因此使用commute一定是在即便无重试,也不影响结果的情况下才可以
commute的函数需要是可重新排序,而不会影响程序的语义,也就是说commute应该只用在可以对修改ref状态的操作进行重排的场景中
注意:commute和alter的不同
- alter的返回值是要更新到ref的全局状态的值,这个事务内的值就是最终提交的值,而commute产生的事务内的值不一定是最终提交的值,因为所有被commute的函数在最终提交的时候会利用ref的最新全局值重新计算一遍
- 利用commute对ref进行修改从来不会导致重读,因此不会导致一个事务重试
关于commute和alter的对比代码,见案例
个人理解:alter里面会有大量的重新计算,这些重新计算会浪费很多时间,而在commute里面,不做任何的重新计算,提交前算出来是多少就是多少,确保了速度,等到最后真正提交的时候再算一次,需要注意的是,关于多线程的考虑一定是基于一个cpu内完成的,所以这一切才能完成
update-in的源码:
4.9.2.3 ref-set
和alter的语义是一样的,事务提交前如果ref的状态发生改变,则事务会被重试,只不过ref-set是直接传入一个要设置的值
4.9.2.3 通过校验器来保证本地的一致性
校验器和STM的交互非常方便,如果校验器发现非法状态,则抛出一个异常,然后导致当前事务失败
4.9.3 STM的一些缺点
4.9.3.1 事务内绝对不能执行有副作用的函数
因为事务有可能重试,如果副作用是数据库写操作,那么会多次重复写,因此可以使用 io!宏,当被用在事务中的时候,就会抛出异常,因此可以把有副作用的函数用io!宏包起来,防止被误用在事务里面
4.9.3.2 最小化每个事务的范围
尽量不要写长事务,因为串行以及重试会造成性能的降低
活锁:相当于STM世界里面的死锁,简单说就是事务要一直重试,导致一直无法结束
STM解决事务一直竞争导致导致的活锁,就是barging机制,即老事务和新事务竞争时,强迫新事务重试,让老事务先走,如果老事务还是不能提交,直接抛异常
4.9.3.3 读线程也可能重试
deref对引用类型来说是保证不会阻塞的,但如果在一个事务内利用deref对一个ref解引用,那么是有可能触发事务重试的,但是STM维护了一个事务中涉及ref的历史值,这样即便其他事务提交了新的值,当前事务仍然可以获取它自己的值(从历史值中获取),但如果历史值无法获取(变更次数超过了将当前线程的历史值干掉了),那么仍然会阻塞线程直到获取到最新值或历史值为止
4.9.3.3 write skew
STM保证了ref状态的事务一致性,如果事务一致性依赖一个ref,而对其只有读取没有修改,那么STM就无法通过alter commute set-ref知道事务一致性依赖ref这件事(因为不修改就不会调用这三个函数),那么如果事务读取的ref在其他事务中被修改,有可能导致当前事务依赖的还是旧值,最终提交的话,整个状态就不一致了,这个情况叫write skew
这个时候用ensure来避免write skew : 对ref进行解引用,但如果当前事务提交之前,ref被修改了,会导致当前事务重试
(ensure a) (alter a identity) (ref-set a @a)这三个的语义相同,都是做了一个“空写”,作用都是保证读出的值在提交之前不会发生修改
4.10 var
var和其他引用类型不同在于,它们的状态不实在时间尺度进行维护的,它们提供的是一个命名空间内全局的实体,可以在每个线程上把这个实体绑定到不同的值
Clojure里面对一个符号进行求值,就是在当前命名空间孕照名字是这个符号的var,并对其解引用以获取他的值,也可以直接引用var,并且手动解引用,#'是(var map)的语法糖,就是直接引用var,然后@是解引用,因此下面两行代码的效果是一样的
map
@#'map
4.10.1 定义var
顶层的函数和值都保存在var中,它们都是利用def或者其引申物定义在当前的命名空间中
私有var:添加名为:private 值为true的元数据即可
私有函数:defn-
文档字符串:就是变量名后面那个可以认为是用以解释的字符串
常量:添加:const关键字
4.10.2 动态作用域
一般情况下,var的作用域在定义它的形式之内,但是var提供“动态作用域”的特性是个例外
添加:dynamic可以利用binding对var在每个线程的值进行覆盖,一般期望通过binding来对根绑定进行覆盖的动态var,会在命名的时候以星号开头,以星号结尾
动态作用域广泛应用,比如提供数据库的配置信息给类库
4.10.3 var不是变量
不要把var和其他语言的变量混淆在一起,def定义的都是顶级var, 本质上是被设计来保存一些直到程序结束都不再改变的值,如果确实希望一种可以改变的东西,那么使用其他引用类型,然后用一个var来保存它
个人理解:var的作用有两个,第一个是与其他引用类型平行的,作用就是保存一个尽量不会变的值,另一个是高于其他引用类型的,即保存其他引用类型
alter-var-root : 对var的根绑定进行修改
4.10.4 前置声明
先定义var,但是不赋值
4.11 agent
agent是一种不协调、异步的引用类型,这意味着对于一个agent的状态的修改与对别的agent的状态的修改是完全独立的,而且发起对agent进行改变的线程跟真正改变agent值的线程不是同一个线程
agent和atom、ref区分开的特点:
- 可以安全地利用agent进行I/O以及其他各种副作用的操作
- agent是STM感知的,因此它们可以很安全地用在事务重试的场景下
4.11.1 agent action和更新函数send send-off
更新函数及参数整体叫做agent action,更新函数调用只是简单的把action放到一个action队列上,然后在一些专门的线程上串行执行,每个action的结果都是agent的一个新状态
send send-off区别是是否会在一个固定大小的线程池中运行
send:固定大小线程池,不适宜发送阻塞的action,因为会阻止其他cpu密集型action更好地利用cpu资源
send-off:不限制大小的线程池(跟future用的同一个线程池),适宜发送阻塞的、非cpu密集型的action
使用send发送到任何代理的所有操作都在一个线程池中运行,该线程池中的线程数比处理器的物理数量多。这导致它们接近CPU的全部容量。
如果您使用send发出1000个请求,则实际上不会产生太多的切换开销,无法立即处理的呼叫只需等待处理器可用即可。如果它们阻塞了,则线程池可能会耗尽。
使用send-off时,将为每个呼叫创建一个新线程。如果您发送了1000个请求,那些无法立即处理的函数仍会等待下一个可用处理器,但是如果send-off线程池碰巧处于运行效率较低的状态时,它们可能会导致启动线程的额外开销。线程阻塞发生阻塞是可以接受的,因为每个任务(潜在地)都有一个专用线程。
如果需要等待的话,则使用await方法
4.11.1 处理agent action中的错误
因为agent action是异步的,所以action抛出的异常和发送这个action的不是一个线程,对这种情况的默认处理策略是将agent默默地挂掉,我们可以解引用获取最后的状态,但是进一步发送action会失败
尝试给一个挂掉的agent发送action会返回导致这个agent挂掉的异常
agent-error函数可以用来获取异常
restart-agent可以重启一个挂掉的agent,它将agent的状态重置为我们提供的值,并且使它能够继续接收action
:clear-actions标志位可以将agent队列上面阻塞的action全部清除掉
4.11.1.1 agent的错误处理器以及模式
agent支持两种失败模式,是通过:error-mode确定的:
:fail(默认值):一个错误会导致agent进入失败状态
:continue:执行时如果抛出异常,这个异常会被直接忽略掉继续执行队列中其他action,并且也能继续接收新的action,这使得我们没必要调用restart-agent,但是忽略掉错误而不做任何事情并不合适,因此会制定一个错误处理器
错误处理器::continue模式下使用,这是一个接收两个参数的函数,分别是发生错误的agent以及这个异常对象,通过指定:error-handler来指定,处理器中可以进行很多其他的操作,P214
4.11.2 I/O、事务以及嵌套的Send
agent可以非常安全地被利用来协调I/O或者其他类型的阻塞操作,而且由于agent的简单语义,使得它们可以称为简化涉及异步I/O操作的理想组件
4.11.2.1 利用agent来并行化工作量
将发送agent的方式分成两种,即阻塞性和非阻塞性的action,因为这样可以利用它们最有效地使用不同资源的能力,比如cpu、io、网络等
4.12 使用Java并发原语
这一部分就和Java里面的使用基本类似了,实际开发中用到的也不是很多,不再赘述
上一篇:《Clojure编程》笔记 第3章 集合类与数据结构
下一篇:《Clojure编程》笔记 第5章 宏
最新文章
- StringIO和BytesIO
- 渗透测试报告收集、生成工具MagicTree
- c# List 按类的指定字段排序
- php利用iframe实现无刷新文件上传功能
- C++ primer札记10-继承
- ural1519插头DP
- VB.NET调用SQL Sever存储过程
- javaweb之session过期验证
- 整个IT界可分为13块大领域
- Deep Learning with Torch
- Vue 进阶之路(四)
- easyui的tree节点的获取和选中
- 使用 Oracle Data Access Components连接oracel
- Codeforce 294A - Shaass and Oskols (模拟)
- 用servlet实现用户登录案例
- SSH黄金参数
- 滑雪---poj1088(动态规划+记忆化搜索)
- RMAN中%d %t %s %u,%p,%c 等代替变量的意义
- ubuntu下交叉编译ffmpeg
- (转)JavaScript内存模型
热门文章
- Centos-挂载和卸载分区-mount
- SQL数据库删除和还原的时候提示 被占用 记录一下
- Sticks(UVA - 307)【DFS+剪枝】
- 对ACE和ATL积分
- BeetleX之webapi自定义响应内容
- Linux_centOS_5.7_64下如何安装jdk1.8&mysql
- 多测师讲解接口测试 _报错_高级讲师肖sir
- 多测师讲解自动化--rf断言(下)--_高级讲师肖sir
- 多测师讲解python_安装001.1
- MeteoInfoLab脚本示例:TRMM 3B43 HDF数据