Julia并行计算笔记(一)

本文是《Julia语言程序设计》(魏坤)第14章的读书笔记,加入了很多自己测试和官方文档的内容。内容基本上完整覆盖,不过对照原著风味更佳。全文很长,分为五篇,这是第一篇。

一、进程、线程

进程是线程的爸爸。进程是程序的执行实例,可以从操作系统获得资源,是资源分配的最小单位。进程是线程的上级,可以包含多个线程,其中必有一个主线程。线程是进程的下属,从进程获得资源。线程是进程的不同执行路径,好比是一条河沿着多个支路分流一样,所以多个下属线程共享同一上级进程的资源。

从硬件上讲,CPU单个核心只能运行一个进程。为了满足执行多个进程的需求,操作系统会使用所谓的“进程调度”,创建一个“调度表”来安排多个进程交替执行,看起来像是在同时执行,称为”伪并发“。所以单核心的电脑也可以一边写Word一边聊QQ,而不必等待QQ进程结束后才能打开Word。为了进一步提高并发性(提高并发性有助于充分利用CPU),现代操作系统引入了线程概念,相当于把一个核心分解为多个”虚拟核心“,每个虚拟核心运行一个”线程“。一个进程分解为多个线程之后,可以分配到多个核心上跑,而不局限于一个核心上。但注意这也需要CPU本身的支持。Intel CPU就支持这种分解,称为超线程技术,一个物理核心可以分为两个虚拟核心。早年的AMD CPU不能分解,所以只好堆叠更多的物理核心来达成类似的并发性能,但最近的锐龙系列也整出了超线程,看起来不错。

在Julia中,Distributed包提供并行化和分布式功能。故在一切开始前,请先using Distributed

Julia目前提供的主要是进程级和协程级(下一节讲)并行。进程分为“本地进程”和“远程进程”,用一个PID编号标记。本地进程有且只有一个,称为主进程,PID=1。远程进程称为Worker。默认情况下,使用julia命令打开的REPL只拥有一个主进程。通过addprocs(N)来增加N个Worker,其编号依次为PID=2,3,4,…,N+1,或rmprocs(k)删去第k个Worker。注意无论有没有删除过Worker,新添加的Worker都会从N+2开始编号。也可以在终端中使用julia -p n命令直接开启拥有n个Worker的REPL,此时-p参数会自动加载Distributed包。

可以用procs()查看所有进程的PID,workers()查看所有Worker的PID。用nprocs()nworkers()查看进程和Worker数量。一般地,总有nprocs()=nworkers()+1。但是,当没有远程进程时,主进程会被视为一个Worker,因此nworkers()=nprocs()=1

Julia也提供测试版本的线程级并行功能。多个线程之间需要有一个”协调者“,称为”条件变量“。条件变量是所有线程可见的全局变量,可以改变状态。它相当于一个公共广播信号,通知各线程等待或执行。注意条件变量会被任一线程修改,故也可能产生冲突,所以通常和所谓的互斥量(Mutex)结合使用。互斥量相当于条件变量的”秘书“,负责接待每一个前来拜访的线程,确保每次只有一个线程接触到条件变量。至于互斥量是什么原理,就比较深奥了。

二、协程

协程(Coroutine)既不是进程也不是线程,但可以理解为一种”虚拟的、拼凑出来的小线程“。假如把进程比作车流,线程就是车。把一百辆车打碎,再拼装为一千辆“小车”,这些”小车“的运行效率会比原本的车辆更高。这样的”小车“就称为一个协程。通常来说,协程比线程更小,执行效率更高,也更易于使用,因此也被称为”用户态的轻量级线程“或“绿色线程”。

总结一下,进程、线程、协程是三种层次的并行机制。每个并行机制都会在切换时额外消耗时间成本,根据消耗高低排序为:进程>线程>协程。我们应当根据需求,选择合适的并行层次。目前的Julia不推荐用线程。

在Julia中,称协程为Task。创建Task的方式有两种:

  • taskname=Task(f)
    -用构造方法Task(f)将一个函数对象f封装成名为taskname的Task。此时要求f必须是无参数的,即没有参数或所有参数都有默认值。假如f有参数,那么f就变成了表达式,在传入Task()前会被执行,于是传入Task()的不再是函数对象而是执行结果。为解决此问题,可以定义一个f1()=f(参数),然后taskname=Task(f1)
  • taskname=@task 表达式
    -用宏命令@task将一个表达式封装成名为taskname的Task。留心大小写。

创建后,可用istaskstarted(taskname)istaskdone(taskname)查看Task是否启动和结束。Task有五个状态:runnable(可被启动), waiting(阻塞等待中), queued(正被调度中), done(执行结束), failed(执行异常结束)。Julia内部有一个调度器,负责维护task运行队列。用schedule(taskname)可以把Task加入队列并启动,随后会自动返回done状态,表示已完成。

对于表达式,有个”合二为一“的宏命令:@async 表达式。它会创建Task并直接启动。例如:

julia> using Distributed

julia> a=zeros(1,5)
1×5 Array{Float64,2}:
 0.0  0.0  0.0  0.0  0.0

julia> @async fill!(a,4)
Task (done) @0x00000000063059f0

julia> a
1×5 Array{Float64,2}:
 4.0  4.0  4.0  4.0  4.0

我们可以在被传给Task()的函数对象f内部使用某些命令强迫这一Task改变状态,包括:

sleep(N)  睡眠Nyield()  请求切换为其他task
yieldto(taskname)  请求切换为指定的task,一般不建议使用

三、通道

通道(Channel)是一种具有阻塞特性的先进先出队列,相当于两头开口的管道。声明方式为:

channelname=Channel{类型}(大小)

这里我们把放入Channel中的对象称为“元素”。若声明Channel时不指定元素的类型,则默认为任意类型。

创建Channel后,用put!(channelname,元素)将元素放入Channel,用take!(channelname)从Channel中提取一个排在最前的元素。take!()会将元素从Channel中移除,若不想移除则可使用fetch()代替(注意fetch没有感叹号)。当Channel为满/空时,put!()/take!()会阻塞(即暂停执行),此时提取/放入元素可以释放put!()/take!()的阻塞。也可以用for结构遍历提取Channel中的元素,例如:

julia> c=Channel(2)
Channel{Any}(sz_max:2,sz_curr:0)
   
julia> put!(c,10)
10
    
julia> put!(c,11)
11
    
julia> for x in c
			print(x," ")
	   end
10 11

这里for结构本质上是一串自动执行的take!(),和take!()一样会移除元素。当Channel变为空时,for结构也会阻塞。

close(channelname)可以强制关闭Channel的入口并释放所有put!()take!()的阻塞。此时不能再放入元素,但依然可以提取直到全部取完。

Channel是一个公共缓冲区,可被多个Task并发地、安全地读写,多个Task之间放入/提取元素的顺序是任意的。举例说,你可以用函数或Task把10个元素放入一个Channel中,然后启动2个Task并行地提取元素。这两个Task会在调度器安排下以任意顺序交替提取直到取完。这种任意顺序的特点决定了每个Task不一定恰好获得5个元素,除非在Task函数内部显式地指定提取元素的总数。

这个例子中,我们把放入元素的函数或Task称为生产者,提取元素的Task称为消费者,便归结为所谓的“生产者/消费者问题”。Channel相当于一个市场。而且实际上它不只提供单向的买卖,因为消费者也可以同时是生产者,生产并互相交换数据。所以说Channel是一个为并行设计的、方便的、安全的数据交换区。

当然,这里隐含的问题是怎么确保放入和提取的合适顺序。首先,Channel是有序的,可以根据先进先出的原则去设计Task。而堵塞特性使得放入和提取会自动等待,利用这一点就可以设计出合适的顺序。其次,wait()函数可以帮助我们调整顺序:

wait([x])

Block the current task until some event occurs, depending on the type of the argument:
  * [`Channel`](@ref): Wait for a value to be appended to the channel.
  * [`Condition`](@ref): Wait for [`notify`](@ref) on a condition.
  * `Process`: Wait for a process or process chain to exit. The `exitcode` field of a process can be used to determine success or failure.
  * [`Task`](@ref): Wait for a `Task` to finish. If the task fails with an exception, the exception is propagated (re-thrown in the task that called `wait`).
  * [`RawFD`](@ref): Wait for changes on a file descriptor (see the `FileWatching` package).
If no argument is passed, the task blocks for an undefined period. A task can only be restarted by an explicit call to [`schedule`](@ref) or [`yieldto`](@ref).
Often `wait` is called within a `while` loop to ensure a waited-for condition is met before proceeding.

wait(r::Future)

Wait for a value to become available for the specified [`Future`](@ref).

wait(r::RemoteChannel, args...)

Wait for a value to become available on the specified [`RemoteChannel`](@ref).

最后介绍一个“一步到位”的技巧,称为“通道绑定”。分为两个步骤:

  1. 声明一个参数(虚参)有且只有Channel的生产者函数p()
  2. 创建一个参数(实参)有且只有上述生产者函数p的Channel。

这样创建Channel时,p会被封装为Task并启动,当Task运行结束时,Channel也会随之自动关闭。也就是说,把整个生产、放入、关闭的过程简化了,直接把放好货物的市场提供给消费者。例如:

julia> function producer(c::Channel)
       put!(c,"start")
       for n=1:4
           put!(c,n)
       end
       put!(c,"stop")
       end;

julia> for x in Channel(producer)
           println(x)
       end
start
1
2
3
4
stop

可以看到producer里的put!()没有阻塞,因为Channel会自动检测producer内部所有的放入操作并划分出合适的缓冲区,在所有放入完成后自动关闭。这种生命周期关联的机制称为“绑定”。也可以用bind(channelname,taskname)绑定已经创建好的Channel和Task,此时的Channel可以任意大小(绑定时会自动调整),而Task的函数仍必须有且只有一个Channel类型的虚参。但要注意,如果Task封装的是表达式则是采用引用方式,即在put!(c,元素)中必须c=channelname。例如:

julia> c0 = Channel(0)
Channel{Any}(sz_max:0,sz_curr:0)

julia> task = @async foreach(i->put!(c0,i),1:4)
Task (runnable) @0x0000000007cbd150

julia> bind(c0,task);

julia> for i in c0
       @show i
       end;
i = 1
i = 2
i = 3
i = 4

上文修改任一处c0都会报错。

特别地,我们可以反复利用bind()把多个生产者绑定在一个通道上。

猜你喜欢

转载自blog.csdn.net/iamzhtr/article/details/91348595