协程,异步式编程以及ReactiveX

上半年主要在忙写书的事情,下半年由于工作有点忙以及自己犯懒没有写博客。不过个人还是在持续学习,接下来是个人关于协程等的学习的一个小结。

严格来说协程不是一个新名词,但是因为Golang变得很火。很多博客从进程-线程-协程模型讲Golang如何高效,比那些没有协程的对手,比如Java好不知道到哪里去。先不管这种想法有点偏颇,个人想从协程想要解决什么问题开始,分析各种语言如何解决这些问题,来更深入地了解现代编程。

协程可以解决什么问题?首先三层调度模型(进程-线程-协程)中协程属于用户态任务调度,原先程序用线程来考虑问题,现在用任务粒度。程序怎么划分任务可以由程序员指定,也可以在特定的地方划分,比如IO操作。

IO操作相比计算型操作不怎么需要计算型资源,所以在等待IO操作返回时CPU资源是被“浪费”掉的。其次,非阻塞型IO的出现,使得编程方式发生了很大的变化。当你在考虑用额外的线程来处理IO阻塞时,非阻塞型IO可以少用额外的线程,只需要等待完成事件。

问题是“等待完成事件”对很多人来说个全新的东西,你可能需要一个轮询IO事件的selector(典型的reactor)以及相应的基于事件(event)编程的模式。你可以在JavaScript的运行时中看到这种模式。一般来说,语言层通过callback绑定后续操作。如果你有多个IO操作的话,你可能会写出callback嵌套callback的代码(或称callback hell)。JavaScript针对callback hell的解决方案是Promise,以及之后的aysnc/await。Promise允许链式callback,async/await允许用近似同步的方式编写异步的代码。

不管你是否感觉到,但是async/await确实做了类似协程的事情。

注意,JavaScript由于是单线程模型,本身又有event loop,在实现async/await时并不需要对运行模型进行修改。其他语言想要实现async/await的话,除了语法层面,运行层也需要修改(在Erlang和Golang中,使用的协程运行时是一个“多线程版本的event loop”:work-stealing thread pool)。

小结一下,协程可以用于解决异步IO的callback hell问题。事实上,个人认为这是协程的优势。对于同样使用异步IO的程序来说,使不使用协程在性能上没有太大差距,但是用协程写法上会简单很多。至于Golang中使用go块开启协程的写法,个人认为是线程运行模式的补偿。比如你有一个非IO的长期计算任务,你没有IO操作的切入点和结束点,必须由程序员指明(用go关键字。BTW,因为这种任务的存在,协程运行时必须使用work stealing thread pool,否则被放到这个线程末尾的任务很长时间内不会被执行到)。

虽然协程可以简化异步IO调用,但是存在一些问题无法直接解决。比如说,我希望同时执行IO调用1,IO操作2并等待两个结果返回。如果你使用顺序IO操作1,IO操作2写的话,实际执行不会是并行的。在JavaScript下,你需要退回Promise并使用特定的API Promise.all。在Golang下,你需要主动开启两个go块执行IO操作,然后使用channel获取结果。

个人认为这其实显示了基于协程编程的脆弱性,一旦遇到不是简单的单次IO操作的场景,复杂性就会显现。又比如说,执行一个操作,你希望设置一个超时时间,在Golang中你必须同时使用channel/select,外加一个timer才能实现。相对的,同步编程下只需要在参数里加一个timeout。

可以看到,这里的复杂性其实是异步编程,或者说多线程编程的复杂性。JavaScript由于不是多线程,可以干脆放弃async/await的写法回归Promise+链式callback的做法。Golang使用了CSP编程的核心要素channel来处理多线程编程(BTW,Golang的channel可以与协程整合在一起)。

从个人角度来说,JavaScript的做法可能更好:假如你没法用协程了,那就退一步用不那么直接但是足够简单的方式,而不是退十步,用通用(即CSP)的方式。Golang很多人说简洁,但是由于其设计目标(比如工程化),只对基本的场景做了对应,剩下的场景就显得很简陋(C语言风格)。

除去Promise.all这种专用API之外,其他语言和框架中比较容易理解的,个人觉得是ReactiveX的zip,比如如下代码

这里没有暴露出任何和多线程相关的信息,但是可以达到同时执行的效果。进一步,假如你需要同时执行多个操作,只要有一个返回结果了就结束,在JavaScript、Golang和ReactiveX中你可以考虑下分别该怎么做(一个提示是,ReactiveX可以用amb替换zip)。

你可以把ReactiveX的subscribe当作JavaScript的callback,它会在获取到结果后被执行。和JavaScript的event loop不同,ReactiveX在多线程环境下执行时,subscribe的代码可能会在操作线程中执行。这对于Android开发来说可能会有问题(不能在GUI线程之外修改GUI元素),但是你可以通过简单增加一行observeOn(xxx)来解决这个问题。

注意,ReactiveX的数据流模型是一种PUSH模型,即数据流负责提交数据,触发下游操作。这某种程度和协程结束触发后续代码很像。其次ReactiveX允许你使用函数式语言的方式处理错误,集中精力在核心处理代码上。

当然ReactiveX不是万能的,在多线程编程方面,ReactiveX只能提供有限的帮助。更多的时候,你仍旧需要使用各种针对多线程处理的工具。以及,多种编程范式有助于你用各种高效的方法来解决问题,比如说Kotlin既有协程,也有async/await,也有ReactiveX的实现,当然你也可以用传统线程。个人认为这是现代编程对于愈来愈复杂的问题的解决方案,相应地这也需要程序员对语言使用对问题分析有足够的能力。

最后,以一句个人最近看到的话结尾“协程的本质是异步编程”。假如你能看到协程所要解决的问题,以及了解如何解决这些问题,那么相信你的学识会让你比其他人有更多手段和方法来更好地解决问题。

【C++11】异步执行之既有函数的包装:packaged_task类和async方法

上篇中讲到,C++11的标准库提供了promise用于在线程执行的具体方法中返回数据,接收端通过future阻塞获取。这么做的前提是你可以修改方法的参数,或者说你需要写一个包装函数。想要让既有函数异步的话,你可以使用packaged_task类或者async方法。

具体分析之前,以下代码是在线程中需要执行的方法。

MyString是很早之前自己用来查看copy/move次数的类,不想用的话,可以替换为std::string。

packaged_task

packaged_task是一个封装了被调用的函数的task。注意,packaged_task本身并不提供异步执行的机制,所以你仍旧需要把packaged_task放到thread中去执行。

Continue reading “【C++11】异步执行之既有函数的包装:packaged_task类和async方法”

【C++11】基于std::thread异步执行时的输入输出

本篇主要是记录自己在学习C++11下std::thread异步执行时的一些细节性的东西,为之后基于C++11写并发代码打基础。

C++11引入了std::thread。据说之前因为需要区分对待pthread和win下的线程库,代码中有大量的预编译的if else,非常丑陋。现在的话,统一用std::thread就行了。

基于std::thread最简单的异步执行代码。

Continue reading “【C++11】基于std::thread异步执行时的输入输出”

【C++11】字符串与常用数据结构

学习一门编程语言,考察编程语言支持的基本数据结构是很重要的。如果你以前学的C/C++倾向于自己造轮子,或者你有其他语言背景的话,建议重新了解一下C++11 标准库中的数据结构。

字符串 std::string

你可以用 const char* 也就是字符串字面量来构造 std::string ,也可以从 std::string 中获取 C风格字符串的指针(const char*)。

std::string 是可变的,所以你可以修改 std::string 而不用太担心性能

关于不可变字符串,有很多讨论,这里列举一下想要用不可变的“字符串”话,在不用其他库的情况下可以怎么做

  • const char* 如果自己分配的字符串数组的话,需要记得delete。字符串字面量的话不用担心。
  • const std::string& 给 std::string 加const,严格来说这只是防止修改
  • 自己造轮子

std::string 支持 copy 和 move

std::string 的 substr 返回的是 copy 过的子字符串。

C++17开始支持 string_view。在C++17之前想用“字符串视图”的话,你可能要自己构造一个类似下面这种包装结构

std::string 的 = 是字符串比较,而不是地址比较。

std::string 提供的相关方法不多,现有的比如查找find/rfind

需要注意找不到时返回的不是-1,而是npos,一个特殊的值

std::string 提供了两个获取字符的方法,at 和 [],前者加了范围检查,后者没有

支持用迭代器方式遍历字符串

关于UTF-8字符串,可能和 std::string 没有直接关系,但是C++11中的字符串字面量

1是依赖于系统编码的字符串字面量,2是UTF-8编码的字符串字面量。

std::string 没有直接支持UTF-8的方法,size返回的是字节数,如果你用迭代器遍历的话,是按照逐个byte的方式(虽然类型叫char)

Continue reading “【C++11】字符串与常用数据结构”

【C++11】字符串拼接之回归原点

在其他语言里,字符串拼接可能是一个常见而且基本不会去注意的部分,但是在C++中字符串拼接有非常多的解决方法。造成这种现象的原因是,C++程序员想要高效地拼接字符串。

比如说下面的代码

对于有非C/C++语言的人来说可能最平常不过的代码,C++程序员可能直觉上不会采用这种写法。那么C++里面该用什么写法呢?或者说最佳实践是什么?

这里不会列举各种字符串拼接的方式,如果你有兴趣可以在StackOverflow上搜搜看。个人想要说的是:在分析了C++11里字符串的操作之后个人给出的结论:C++11里最佳的字符串拼接其实就是上述写法。以下是具体分析。

Continue reading “【C++11】字符串拼接之回归原点”

【C++11】move构造函数和std::move

如果说新的语言特性使得过去的最佳实践不再成立的话,我想move构造函数和std::move所代表的move语义应该算其中一个。

在解释move引起的变化之前,这里先定义一个支持自定义move操作的类

Continue reading “【C++11】move构造函数和std::move”

【C++11】从std::string str = “foo”说开去

最近因为某些原因决定重新开始学习C++。考虑到自己在大学里面学到的C++有点旧(估计是C++98),所以打算从C++11开始。

C++11如其名,是2011年出来的标准,所以2011年之后才有编译器实现。现在2019年大部分PC以及服务器应该都支持C++11了。

个人习惯于看书来学习某样东西,所以找了C++相关书的资料。一开始在o’reilly上找,发现很多书都比较旧。虽然有Effective C++以及More Effective C++系列,但对于初学者来说还不是时候。最后在Stackoverflow上找到了一个比较全的推荐书列表

https://stackoverflow.com/questions/388242/the-definitive-c-book-guide-and-list

Continue reading “【C++11】从std::string str = “foo”说开去”

Java并发研究 自己写ReentrantLock和ReentrantReadWriteLock(4)

上篇。在写完ReentrantLock之后,其实可以基于ReentrantLock写一个ReadWriteLock,《the art of multiprocessor programming》第八章有介绍。但是,本着不完全AQS(AbstractQueuedSynchronizer)介绍的系列主题,这里从零开始重新写一个ReentrantReadWriteLock。

按照ReadWriteLock的定义,任何时候都满足

  1. 没有线程持有锁
  2. 有1~n个线程持有共享锁(Read)
  3. 有1个线程持有独占锁(Write)

中的一个。

其次公平的ReadWriteLock要求新来的Read或者Write线程必须在队列中等待,非公平的ReadWriteLock允许新来的Read或者Write比队列中等待的线程先获取锁。关于非公平锁这里多说一句,理论上的非公平锁类似一群人哄抢的现象,但是实现多半是只允许新来和线程队列最前面的线程抢占锁。ReadWriteLock也是一样。如果你想要完全非公平的锁的话,可能AQS和这里的实现不满足你的需求。

为了实现ReadWriteLock的定义,你需要分别记录读写状态。考虑到独占(Write)状态只可能有一个线程,可能场景如下:

Continue reading “Java并发研究 自己写ReentrantLock和ReentrantReadWriteLock(4)”

raspberry pi 3 b+ cross compile on macos mojave

本文记录了如何在mojave上构建raspberry pi 3 b+的交叉编译环境。交叉编译的原因最主要的还是速度,当然还有目标平台没有直接的编译工具链等。在构建交叉编译环境之前,最好确认目标平台的相关信息,能直接确认目标平台的工具链是最好的,因为从CPU判断可能并不准确。

比如raspebrry pi 3 b+,自带gcc,相关信息如下

注意其中target为arm-linux-gnueabihf。假如从raspberry pi 3 b+的CPU(Broadcom BCM2837 64bit CPU)判断的话,armv8,aarch64貌似都可以用,但实际用aarch64架构编译出来的程序无法在raspberry pi,准确来说是安装了raspbian上的raspberry pi上执行,这点请注意(假如想在raspberry pi 3 b+上执行aarch64架构的程序的话,个人觉得可能要换raspbian以外的系统,或者修改内核编译选项,这块等自己有空尝试并成功了再发出来)。

Continue reading “raspberry pi 3 b+ cross compile on macos mojave”

Compile and install valgrind on macOS Mojave (10.14.2)

本文是给想在Mojave上编译安装valgrind的人一个参考。

个人因为《Hands on concurrency with Rust》这本书的原因,需要安装valgrind。但是现在(2019/2/9)稳定版本的valgrind尚未支持Mojave,即不能通过Homebrew安装。valgrind的bug tracker里有这个问题的追踪, 但是看状态估计离正式发布还需要时间(开源项目常有的事情,缺少资源,哎)。对话中给了一个github上的commit,看起来可以使用。

下载对应的repository,checkout到修改版的branch上

按照valgrind自身网站上的说明,接下来是常规的compile install。

如果你按照顺序执行的话,在make可能会碰到如下两个问题,所以在执行之前,建议先看一下可能碰到的问题

1. No rule to make target `/usr/include/mach/mach_vm.defs’

简单来说,就是没有找到定义文件。按照stackoverflow上一个问题的说法,你可以通过

解决,但是答案针对的不是Mojave,所以Mojave除了通过上述命令安装XCode之外,还需要解答中另外一个解决方案,即修改coregrind/Makefile(此文件在./configure之后生成)中mach_vm.defs的路径,具体如下

也就是在原本 /usr/include/mach/mach_vm.defs 等文件前加上 /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.14.sdk 前缀,你可以 ls 一下看一下文件是否存在。

2. vgpreload_core_x86_darwin_so-vg_preloaded.o ld: symbol(s) not found for architecture i386

这个问题比较隐蔽,google搜不出来什么东西。直接看错误信息的话,大概知道MacOS下无法链接i386的程序,因为比较新的MacOS基本上只有x86_64。直接的解决方法是不让valgrind去编译i386架构的程序。

如果你留意了 ./configure 最后的输出的话,可以看到Secondary build arch

假如Secondary build arch不像上面那样为空的话,比如是i386,那么make时会出现以上问题。

在 configure 文件中,valgrind的注释提到在MacOS下会同时编译i386和x86_64,这是问题的根本原因,或者说valgrind在Mojave下编译的坑。幸好 configure 提供了只编译64位的选项,不需要修改 configure 文件。

总结一下,你要做的是

小结

老实说很后悔升级到了Mojave,MacBook的睡死,突然mic不能使用等问题,现在又来一个valgrind……

不管怎么说,希望本文对你有所帮助。