Skip to main content

异步、并发编程和 defer 延迟

在学习完Yak语言的基础概念和语法之后,笔者将会带领大家开始Yak语言的高级编程技术,本节包含并发异步功能的使用;延迟执行,同步控制,错误处理等重要概念。

6.1 协程与异步执行

6.1.1 什么是同步执行和异步执行#

一般而言,计算机程序按照代码的执行顺序分为同步执行以及异步执行。

首先,在同步异步的主要区别就是是否等待程序操作完成,程序操作是指程序中的一段完成某个功能的代码,可以包括一到多行代码,比如输出信息、读写文件、网络操作等,这都是一个程序操作。

A. 同步执行:#

在程序运行的过程中,程序将会等待操作的完成。程序内的所有操作将会从上到下一步步执行。比如定义一段程序如下:

println("hello")for i=0; i<10;i++{    println("in loop:")}println("end loop ")

在Yak代码执行的过程中程序将会按照代码内定义操作的顺序依次执行,只有前一条操作执行完成再继续执行后续的操作。比如在示例代码中,有三个操作:打印hello,循环打印十次”in loop“, 打印”end loop“。 在顺序执行的程序中将会首先打印hello,然后循环打印“in loop”, 在循环结束以后打印“end loop"。

同步执行好处是顺序简单直观。

缺点是每一条代码都需要等待前面的代码执行完毕才可以执行,假如在程序中间存在一些需要时间才能完成的操作(比如文件的读取写入、进行网络请求等),那么只有在该操作完成后才可以继续执行后续的代码,整个程序会停住等待该操作的完成。这个整个程序停止等待操作完成的行为称为程序的阻塞。

B. 异步执行:#

在异步执行中,程序执行一个操作以后,并不会等待该操作完成,而是继续执行后续的代码。当操作完成的时候将会通过各种手段通知程序该操作运行结束。

异步执行的好处在于可以有效地避免程序的阻塞,缺点是程序需要进行对操作的处理、错误处理、多操作的状态同步,将会比较复杂。

C. 同步执行和异步执行的使用#

同步执行和异步执行都是为了完成程序的任务,只是执行顺序不同,各有优劣需要按需使用。

比如,当程序需要大量的数据读写操作,并且这些数据读写互相无关,那么使用异步执行进行读写可以使程序运行的更有效率,异步执行将会同时启动多个数据的读写操作,而不是等待一个数据读写操作完成在进行下一个。

相反,如果程序的操作之间有依赖关系,比如需要先读取配置文件,然后根据配置文件内容启动其他的操作,那么其他所有操作都需要等待配置文件读取这个操作执行,这时候需要使用同步执行。

6.1.2 异步执行的方式#

计算机操作系统中的程序默认的执行顺序是同步执行的,因此一般情况下一般是在程序的某些互不相关的操作上使用异步执行,使得这些操作可以同时执行,并在一个合适的位置等待所有异步执行的操作执行结束,继续同步执行。

A.进程#

在计算机操作系统中,每一个程序都是一个进程,进程之间是互相不影响的,比如浏览器和文件管理器就是两个不同的进程,他们互相毫无关系,关闭其中的一个也不会对另一个产生影响,而且两个进程都是同时在运行的。

计算机领域最早的异步编程就是使用的多进程的方式进行,也就是程序需要异步执行的操作单独开一个进程来运行,这样就可以原进程可以继续执行而不需要等待该异步操作,而异步操作也如同预期一样和原进程同时运行,操作结束以后新的进程也结束,原进程通过进程之间通讯来获得该操作的结果。这是异步执行最早的执行方案,通过这样一个效率不高的方案读者可以大致理解异步执行的程序行为。

但进程的创建、销毁是需要非常多计算机内存、运行时间的,在小型操作时使用进程实现的异步执行剩下的运行时间还不如进程的创建销毁浪费的时间多,另一方面,进程间的通讯也并不够好用。

多进程的系统中,程序也并非真正的同时运行,在单CPU计算机上,其实是通过进程的快速切换达到“在同一时间段内多个进程同时运行”的效果,这称为进程的并发,多CPU计算机可以将不同进程分配到不同CPU来达到“同一个时间点上多个进程在同时执行”的效果,也就是进程的并行。

需要注意的是,多核心单CPU的计算机其实不一定可以实现并行,如果所有的核心都使用同一套内存管理单元(MMU)和缓存机制,也只能在同一时间执行一个进程,这取决于多核CPU的硬件设计。早期的多核心CPU一般不能支持进程并发。

多进程程序是程序并行的一种形式,但是计算机操作系统中实现多进程更关注的在于进程之间的独立性,每个进程独立运行互不干扰。然而在代码编写的过程中所需要的异步执行只是避免阻塞等待提高程序效率,程序大部分时候只是需要异步计算一些数据或只是等待网络或文件读写响应,对于每一次异步执行所要求的独立性更加低,每段异步执行的代码并不需要拥有太多数据。这一需求有两种解决方案。

B. 线程与协程#

首先在操作系统层面提供了线程,线程存在于进程内,一个进程可以启动多个线程同步执行程序,同一个进程内的多个线程之间共享地址空间,因此在使用上多线程的切换效率和通讯要比多进程更加方便。

操作系统提供的线程会在程序运行的用户态切换到操作系统内核中完成线程的切换,因此,操作系统进一步提供了用户态线程,用户态线程不需要经过操作系统内核就可以进行上下文切换。

同时在用户态也出现了协程的实现,协程与用户态线程非常类似,他们的切换都是在用户态进行的,线程是系统提供的,但是协程是用户态的代码提供的,并且协程可以由程序编写人员控制协程间的切换时机。现在活跃语言都拥有协程的实现,其中有些是通过第三方库实现有些是语言原生支持的。在Yak中提供了原生支持的协程。

6.1.3 如何在Yak中使用异步编程#

在Yak中, 协程运行的基本单位是一个函数,创建协程异步执行的语法和普通的函数调用类似,只需要在开头加上"go"关键字即可。以下是语法示例:

go 函数名(参数列表)

以下是一个简单的代码案例:

func count() {    for i := 1; i <= 5; i++ {        println("count function:\t", i)        sleep(1)    }}
count()for i=1; i<=5; i++ {    println("Main function:\t", i)    sleep(1)}sleep(1)

在这个例子中, count是一个函数,他的作用是循环5次打印count function: i, 在程序运行的时候,将会直接调用该函数,函数执行结束后将会继续执行后续代码,仍然循环5次 打印Main function : i, 程序将会产生以下输出:

count function:  1count function:  2count function:  3count function:  4count function:  5Main function:   1Main function:   2Main function:   3Main function:   4Main function:   5

可以看到,一直等待到count函数执行结束才继续向后运行后续的循环,这就是一个同步执行的示例。

接下来,在函数调用的时候加入"go"关键字,将会使count函数异步执行, 异步执行代码示例如下:

func count() {    for i := 1; i <= 5; i++ {        println("count function:\t", i)        sleep(1)    }}
go count()for i=1; i<=5; i++ {    println("Main function:\t", i)    sleep(1)}sleep(1)

这一示例执行结果如下:

Main function:   1count function:  1Main function:   2count function:  2count function:  3Main function:   3count function:  4Main function:   4Main function:   5count function:  5

可以看到程序将不会等待count的执行结束直接开始执行后续代码,而count函数也同样在执行,两段循环在同时执行,这就是count函数在异步执行的效果。

6.2 延迟运行函数:Defer

在编程中,有时程序希望在函数执行完成后执行一些清理操作或释放资源的操作。例如,可能需要在打开文件后关闭文件,或者在数据库操作后关闭数据库连接。延迟执行机制提供了一种方便的方式来处理这些情况。

延迟执行的基本单位也是函数,通过在函数调用前增加"defer"关键字,可以指定某个函数调用延迟执行,使这些函数调用将会在当前函数返回时自动执行。

这意味着无论函数中的控制流如何,这些延迟语句都会在函数返回之前被执行。这种机制可以确保无论函数是正常返回还是发生了异常, 在该函数执行完毕后都一定会进行设置好的必要清理工作。

需要注意的是,在Yak中执行编写代码,默认是写入在主函数内的。因此在此时也可以直接使用“defer”延迟执行,在主函数内所有代码(也就是编写的代码)全部执行结束后,将会自动调用设置的延迟执行函数。

以下为延迟执行的关键字"defer"语法。

defer 函数名(参数列表)

6.2.1 创建延迟函数#

以下是一个简单的代码样例:

println("statement 1")defer println("statement 2")println("statement 3")
subFunc1 = func(msg) {    println("in sub function 1: ", msg)}subFunc2  = func() {    defer subFunc1("call from subFunc2 defer")    subFunc1("call from subFunc2")}subFunc2()

在这个示例中,在主函数,程序在defer关键字进行延迟执行println("statement 2")这次函数调用,定义subFunc1subFunc2两个函数, 主函数将会调用subFunc2函数,并在subFunc2中,通过普通调用和延迟调用来拿各种功能方式调用subFunc1函数。当运行此程序的时候,将会产生以下输出:

statement 1statement 3in sub function 1:  call from subFunc2in sub function 1:  call from subFunc2 deferstatement 2

可以看到使用"defer"关键字进行延迟执行的函数在整个函数执行结束以后才运行, 对于主函数来说,"statement 2"是在所有代码执行完毕后运行的; 对于subFunc2来说,带有"defer"的调用是在该函数运行结束返回的时候运行的。其他的普通函数调用将会按照代码顺序执行。

6.2.2 多个延迟函数#

程序可以设置多个延迟函数,这些延迟函数将会被保存在一个先入后出的栈结构内,程序结束以后,将会依次从栈中弹出执行,也就是多个函数将会优先执行后定义的函数,从后向前执行。接下来的例子将会展示这一特性:

println("statement 1")defer println("statement 2")defer println("statement 3")defer println("statement 4")println("statement 5")

程序运行结束以后,将会从后向前执行定义好的延迟函数,该代码示例运行结果如下:

statement 1statement 5statement 4statement 3statement 2

6.2.3 程序出错时也会运行延迟函数#

程序出错时也会执行其中的延迟函数,接下来的示例将会展示这一特性:

defer println("defer statement1 ")a = 1 / 0 defer println("defer statement2 ")

该程序运行到1/0的时候,将会触发错误,程序将崩溃,而此时已经通过defer关键字设置了"defer statement1"的延迟函数调用,于是,该代码示例运行结果如下:

defer statement1 Panic Stack:File "/var/folders/8f/m14c7x3x1c55rzvk5qvvb1w00000gn/T/yaki-code-287898179.yak", in __yak_main__--> 2 a = 1 / 0
YakVM Panic: runtime error: integer divide by zero

值的注意的是,由于该代码在第二行1/0崩溃,因此第三行中的defer并没有被执行,也就不会被调用,仅有第一句中设置的延迟函数被调用了。

6.2.4 小结#

函数延迟执行是一个简单但是实用的机制,通过延迟执行可以保证数据清理和资源释放操作。在本章(第六章)后续的错误处理和并发控制两个小节将会介绍更加具体两种的使用。

6.3 函数的直接调用

在第五章中已经详细讲解了函数的创建方式和直接的调用,一个代码的样例如下:

func a() {    println("in sub function 1")}a()

在很多时候,临时的函数不一定需要被定名,可以直接定义函数并调用。比如如下的代码:

func() {    println("in sub function 2")}()

在Yak中进行协程创建和延迟运行的时候都需要编写一个函数调用,很多时候会创建一个简单的临时函数,并不给他定名然后调用,将会编写类似上述示例的代码,Yak对这种情况提供了更加简单的方案:

func {    println("in sub function 3")}

这样的函数等同于上述的两个函数调用。

在go关键字和defer关键字后,也可以编写这样的代码:

defer func {    println("in defer")}go func {    println("in go")}println("sleep 1")sleep(1)

这样的程序似的程序编写的更加简洁,他的运行和定义函数进行调用是等效的,运行结果如下:

sleep 1in goin defer

6.4 并发控制:sync

在本章中已经提到协程的创建和使用,创建一个协程的开销很低,远远低于线程,是进行异步编程的重要基础。但在Yak的异步编程中,需要考虑两个问题:

  • 异步编程处理数据的时候,需要有手段可以等待所有期望的协程结束,然后收集资源。
  • 创建协程是有开销的,无限制地去创建协程只会让资源被白白浪费掉。需要有手段可以控制某个功能创建协程的数量上限。

对于协程的并发控制,在Yak提供了许多并发控制的支持。

6.4.1 等待异步执行: WaitGroup#

下面请看这样一段代码样例:

for i in 16 {    num = i     go func{        sleep(1)        println(num)    }}println("for statement done!")

在这段代码样例中,首先运行循环,在循环内创建协程打印数据,并且每个协程都会调用sleep函数等待1秒,最后打印循环结束的字符串。

但是这段程序的运行结果如下:

for statement done!

可以看到只有程序最后的输出。

出现这一情况的原因是,协程都是互相独立运行的,主程序也是一个单独的协程,程序在循环中创建了16个协程,算上主协程一共有17个协程,在循环内创建的协程都会等待1秒然后在打印数据,而主协程将会继续执行,主协程执行结束之后整个程序将会停止运行,所有的其他协程都会被销毁。

为了解决这样一个问题,Yak提供了等待协程的工具:WaitGroup,以下的代码展示了WaitGroup的使用,并通过这一工具解决了前一个代码示例中存在的问题。

wg = sync.NewWaitGroup()for i in 16 {    num = i     wg.Add()    go func{        defer wg.Done()        println(num)    }}wg.Wait()println("for statement done!")

首先创建一个WaitGroup实例。

每次创建协程的时候,调用wg.Add方法,表示增加一个需要等待的协程,在协程内,使用defer延迟函数的形式保证调用wg.Done方法,表示一个需要等待的协程已经结束。

最后使用wg.Wait等待所有注册的协程结束。协程运行到该函数的时候将会阻塞等待,直到所有需要等待的协程都结束,才会继续向后执行。

这段代码运行结果如下:

0271311811536951412104for statement done!

6.4.2 控制协程数量: SizedWaitGroup#

sync.NewSizedWaitGroup是Yak并发控制中一个重要库函数,接受一个字符作为参数表示协程的容量上限,返回一个SizedWaitGroup对象。可以简单地认为SizedWaitGroup是一个计数器,计数器的值就是协程的数量。程序可以通过Add方法使计数器的值增加,使用Done方法使计数器的值减少。如果计数器的值增加到了设置的容量上限,那么Add函数就会堵塞到计数器的值减少为止。

以下的例子演示了SideWaitGroup的简单使用:

swg = sync.NewSizedWaitGroup(1)for i in 16 {    num = i     swg.Add(1)    go func{        defer swg.Done()        println(num)    }}swg.Wait()

在上述示例中,首先创建了一个SizedWaitGroup,容量上限为1。程序运行循环16次,并在每次循环中执行swg.Add(1),然后创建一个协程打印当时的循环计数器,在协程函数内,使用defer进行延迟运行在协程退出的时候执行swg.Done()表达异步执行结束。最后在循环外使用swg.Wait()等待这个计数器归零表示所有协程都运行结束。

同时因为协程都是独立执行,将会分别打印数据,原本应该乱序输出0到15这几个字符,但是现在SizedWaitGroup对象上限为1, 也就是允许并发执行的协程最多为1, 当第一个协程调用swg.Add(1)的时候,SizedWaitGroup到达上限,下次循环运行的时候将会在swg.Add(1)阻塞,等待第一个协程运行结束调用swg.Done()后继续运行,也就导致每一个循环都需要等待前一个循环内的协程运行结束才会运行。这段程序虽然使用了协程运行,但是会表现出同步运行的特性,得到的结果将会是顺序打印0到15。

sync库还提供了很多其他函数可以帮助我们完成并发控制,详情可以查看官方文档。

6.5 通道类型与并发编程:channel

在并发编程中,通信和数据共享是一个核心的问题。Yak语言引入了一种特殊的数据类型 - channel,它就像是一个邮局,可以帮助不同协程之间轻松地发送和接收数据。

本书第三章3.3.3已经简单介绍了通道类型的简单使用,本章节将继续深入探讨Yak中的channel,让读者更好地理解和使用这个强大的工具。

6.5.1 缓冲区和阻塞#

可以将 Channel 理解为一个先入先出的管道,同时可以从一侧放入数据另一侧拿出数据,缓冲区表示在这个管道内保存的数据可以有多少。

我们使用一个程序示例讲解这个特性:

ch = make(chan int, 2) // 创建Channel,缓存区为2
ch <- 1 // 写入数据 此时缓存区[1]ch <- 2 // 写入数据 此时缓存区[1, 2]// ch <- 3 // 写入数据 此时缓存区已经满 将会阻塞等待有数据取出才能写入println(<- ch) // 取出数据 1 此时缓存区[2]println(<- ch) // 取出数据 2 此时缓存区[]// println(<- ch) // 缓存区为空 将会阻塞等待数据写入

当缓存区满时,需要等待取出数据才可以继续向 channel 写入数据,当缓存区空时需要等待写入数据才可以从 channel 取出数据。

同样,如果没有设置缓冲区,无缓存区,表示缓存区大小默认为 0,此时只有两端同时读写才不会出现阻塞等待,否则无论是读还是写都会出现等待。

另一个需要注意的点是,当缓存区空以后继续尝试读取数据,如果是未关闭的 Channel 会导致阻塞等待,关闭的 Channel 则会直接返回nil, false`,当使用for-range或for-in进行数据遍历的时候,当缓存区为空时,未关闭 Channel 一样会等待,已关闭的 Channel 则会跳出循环。在不需要再数据写入的时候,应该关闭 Channel。

6.5.2与协程一起工作#

单独使用 Channel 的阻塞特性可能让人奇怪,但是如果和协程一起工作,则会形成非常高效的并发通讯。

ch1 = make(chan int)ch2 = make(chan int)go fn{    for i=0; i<100; i++ {        ch1 <- i // 在协程中生成0-100写入Channel中    }    close(ch1) // 第一阶段数据写入结束 关闭ch1}go fn{    for {        i, ok := <- ch1 // 获取数据        if !ok {            break // 当 close(ch1)以后 ok=false        }        ch2 <- i + 2 // 从ch1中获取到的数据运算继续写入ch2    }    close(ch2) // 第二阶段数据写入结束 关闭ch2}for i = range ch2 { // 通过for-range读取ch2中的数据    println(i)}

以上的示例中,展示了协程之间数据传输的方案。首先创建两个channel并创建两个协程,第一个协程向ch1中写入0到100,第二个协程从ch1中读取数据,运算并写入ch2, 最后在

数据写入通过ch <- 1进行,数据写入结束以后通过close(ch)关闭channel。

数据读取在代码示例中使用了两种方法:

  • 循环使用v, ok := <- ch并判断!ok的方案,可以读取 ch 内写入的所有数据,直到 Channel 关闭;
  • 另一种方案使用for v = range ch或for v in ch通过循环遍历获取数据,同样是获取 Channel 内写入的所有数据,直到 Channel 关闭。

该代码样例将会打印从2到101的数据,并且由于使用通道进行数据传输,数据保持先入先出的原则,即使是在不同线程数据将会按照顺序打印。