开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第12天,点击查看活动详情 现在是时候看看采用函数式编程的原则如何帮助我们简化在Java中实现并发和并行代码的挑战。到目前为止,我们已经探讨了惰性、不可变性、函数组合、禁止空赋值和抛出异常如何为我们的代码库带来平静和平静。并发性产生了所有bug中最让人抓狂的bug。
让我们从一些功能异常的命令式代码开始
可变非volatile字段
给定一些非volatile的非final字段
势在必行的数据处理
然后,我们有一个多线程processData方法来填充结果列表并设置布尔完成标志。
这段代码现在至少有6个方面存在严重的错误(或者将来可能会开发这些错误)。实际上,用下面的代码调用这个方法会在我的MacBook Air上产生一个无限循环。
locking.processData(in);
while(!locking.isCompleted()){}
让我们来调查一下原因。
- 如果在使用锁时抛出异常,锁将永远不会被释放
- 在我们通过将本地结果添加到resultList字段中来公开它们之后,我们继续处理它们——如果另一个线程也试图读取或写入这些相同的结果对象,则可能会出现竞态条件
- 递增complete可能会出现线程可见性问题和/或与其他线程的竞争条件
- 比较expect和complete可能会导致线程可见性问题和/或与其他线程的竞争条件
- 将complete设置为true可能会导致线程可见性问题
- 递增expect可能会遇到线程可见性问题和/或与其他线程的竞争条件(其中expect在递增之前在单独的线程上读取)。
增长命令式、可变的多线程代码是困难的
所有这些问题都源于我们决定跨线程共享可变状态,并通过锁定调节对该状态的访问和更改。⛔️不要这样做⛔️我们可以发挥一些精神能量,清理这段代码中的漏洞-但这不是正确的方法。这里的挑战是,即使我们(目前)在代码的这一小部分中修复了它,我们可能会有客户机代码(现在或将来)读取该数据,并可能对其执行进一步的处理。在可变数据结构上的跨线程交互的增加将成倍增加获得正确锁定的复杂性和难度。即使我们做对了,并且现在有了合理的性能,将来也必须对代码进行维护。对于我们的代码库和这些交互的本质(或者我们自己在做了几个月的其他工作后)缺乏专业知识的新团队成员会发现很容易引入错误和/或性能问题。
避免突变状态
我们应该重构我们的应用程序,以便在结果到达时不更新可变列表。相反,每个任务应该返回自己的包含部分结果的列表。然后,我们可以安全、高效地聚合这些列表(使用一些函数api)。所有的状态都会变成本地的,我们的方法会返回不可变的值。
这意味着我们可以删除所有当前字段,也许只用Executor替换它们,新的异步任务应该在Executor上运行。让我们看看为什么
我们不需要List resultList,因为我们不会有共享的可变状态
我们不需要跟踪预期的或完整的线程
我们不需要一个布尔值来跟踪整个过程是否完成
由于没有可变状态,我们不需要Lock。
Future
processData方法的方法签名可以是这样的
也就是说,在将来的某个时候,将返回一个结果对象列表。它将异步返回,因此不会阻塞任何调用processData的代码。
本例中的Future是来自cyclops (cyclops.control.Future)的Future,它提供了一个比CompletableFuture更标准的函数API。它包括isDone等方法(我们可以使用它来检查异步任务是否完成)和map等功能链接其他任务的选项。
实现processData
我们可以重构我们的for循环,特别是我们可以用Future创建替换线程创建代码。
Thread::start和Thead::join具有void返回类型,这意味着它们都不提供将结果返回给执行的原始调用线程的机制。跨线程工作和共享结果的能力是Future的核心特性和优点。
我们可以创建一个Future,从磁盘异步加载results
Executor ex;
Future.of(() -> loadResults(id, data.get(id)), ex);
这将给我们一个骨架实现工作:-
在同一个线程上做额外的工作
在最初的命令式版本中,一旦我们在一个新的线程上加载了数据,我们继续处理它(在同一个线程上),对可变的结果对象进行一些更改。我们可以使用map操作符在同一个线程上继续处理Future内部的数据。
在本例中,我们遵循lambda使用单行表达式的最佳实践——processResults方法的方法引用。which的实现应该看起来像这样:-
序列:函数式编程的瑞士军刀
我们可以在List中收集所有的Future实例(使用一个可怕的冗长类型签名🙀-一个Future列表,每个Future都有一个结果列表)
方法的返回类型应该是一个带有结果列表的Future值(而不是一个带有各自结果的Future值列表)
事实证明,这是函数式编程中一个非常常见的问题,幸运的是,有一个非常方便的模式可以用来解决这个问题。
Sequence是一种方法,它接受一个Future列表并将其转换为一个带有List的Future(该模式可泛化到各种函数容器类型)。序列将异步操作,新的Future将以完全非阻塞的方式为我们整理每个已排序的Future的结果。因此,将Future列表(allTasks)转换为带有Results列表的单个Future的代码应该如下所示
注意:我们执行concatMap操作来平展Sequence将在本例中返回的List of Lists(因为每个Future返回一个List)。
将所有这些放在一起,我们的代码仍然有点过于迫切,并且比它需要的更冗长,但是共享可变状态的问题(以及相关的锁和潜在的错误)已经根除了。
如果我们将对loadResults的调用从磁盘移到processResults方法中,并将返回类型从List更改为cyclops强大的流类型ReactiveSeq,就可以减少所需的代码
将来的创建现在可以简化为对流程方法的调用
Future.of(() -> this.process(e.getKey(), e.getValue()), ex)
我们可以用ReactiveSeq和map操作替换命令式for循环
如果我们把它放到Future::sequence调用中,我们可以在一个函数表达式中表达整个方法
这当然比我们开始时的混乱要简单得多,干净得多,简短得多。你可能更喜欢打破这一点,如果你这样做,我强烈建议使用Java 10本地类型推断支持通过var,以避免泛型地狱!
持续加工和SOLID原则
从性能的角度来看,合理的要求是在得到结果时继续处理。在processData返回的Future上使用map方法没有帮助,因为map直到所有结果可用才会被调用。
实现此支持的一种方法是深入挖掘现有流程方法,并向该方法添加所需的任何额外步骤。⛔️停止-不要这样做⛔️。像这样向现有的方法中添加代码并不是最好的方法,至少这违反了SOLID原则中的开闭原则。
在面向对象编程中,开放/封闭原则指出“软件实体(类、模块、函数等)应该对扩展开放,但对修改关闭”;也就是说,这样的实体可以允许在不修改源代码的情况下扩展其行为。
修改流程方法意味着我们依赖于未来的修改(而不是关闭它)——伴随着引入bug的所有风险。这里的其他风险是,通过不断地添加到流程中,我们将在代码中混合高级抽象和低级抽象,出于必要,可能违反依赖倒置原则。此外,以这种方式添加代码有违反单一责任设计原则的风险。这些都不是我们想要的。
异步流
有更好的办法。如果我们需要在数据到达时继续异步处理数据的能力,则需要使该数据作为连续的、异步的(响应性的!)流(不是离散的标量结果)。我们可以重构(简化!)processData的返回类型为ReactiveSeq
目前,processData是通过创建future流并对其排序来实现的。mdoule cyclops-futurestream提供了一个ReactiveSeq的实现(在底层使用了自定义的FastFuture实现)。FutureStream将允许我们在每个结果异步到达时对其应用额外的操作。
总结
一旦你意识到重构并发的函数式风格所需要的诀窍/技巧,它就会成为你的第二天性。关键是使用数据结构(Future ftw✅)或数据流(futurestreams和reactive-streams✅)来异步接收结果,并为您管理跨线程的数据传递。🚫永远不要自己跨线程共享可变状态🚫。从使用期货(或CompletableFutures)开始,然后逐步发展到某种形式的流。FutureStreams是概念上的future流,对于在执行I/O时仍然阻塞的典型Java api非常有用。使用先进的现代api,如RDBC和响应式HTTP客户端,插件一个响应式流实现来获得更好的响应性和性能。