2024年最新Go语言并发详解_go 并发,2024年最新腾讯字节阿里小米京东大厂Offer拿到手软

img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

如果你需要这些资料,可以戳这里获取

for data := range ch {
}

通道 ch 是可以进行遍历的,遍历的结果就是接收到的数据。数据类型就是通道的数据类型。通过 for 遍历获得的变量只有一个,即上面例子中的 data。

遍历通道数据的例子请参考下面的代码。

使用 for 从通道中接收数据:

package main
import (
    "fmt"
    "time"
)
func main() {
    // 构建一个通道
    ch := make(chan int)
    // 开启一个并发匿名函数
    go func() {
        // 从3循环到0
        for i := 3; i >= 0; i-- {
            // 发送3到0之间的数值
            ch <- i
            // 每次发送完时等待
            time.Sleep(time.Second)
        }
    }()
    // 遍历接收通道数据
    for data := range ch {
        // 打印通道数据
        fmt.Println(data)
        // 当遇到数据0时, 退出接收循环
        if data == 0 {
                break
        }
    }
}

执行代码,输出如下:

3
2
1
0

代码说明如下:

  • 第 12 行,通过 make 生成一个整型元素的通道。
  • 第 15 行,将匿名函数并发执行。
  • 第 18 行,用循环生成 3 到 0 之间的数值。
  • 第 21 行,将 3 到 0 之间的数值依次发送到通道 ch 中。
  • 第 24 行,每次发送后暂停 1 秒。
  • 第 30 行,使用 for 从通道中接收数据。
  • 第 33 行,将接收到的数据打印出来。
  • 第 36 行,当接收到数值 0 时,停止接收。如果继续发送,由于接收 goroutine 已经退出,没有 goroutine 发送到通道,因此运行时将会触发宕机报错。

九、Go语言并发打印(借助通道实现)

前面的例子创建的都是无缓冲通道。使用无缓冲通道往里面装入数据时,装入方将被阻塞,直到另外通道在另外一个 goroutine 中被取出。同样,如果通道中没有放入任何数据,接收方试图从通道中获取数据时,同样也是阻塞。发送和接收的操作是同步完成的。

下面通过一个并发打印的例子,将 goroutine 和 channel 放在一起展示它们的用法。

package main
import (
    "fmt"
)
func printer(c chan int) {
    // 开始无限循环等待数据
    for {
        // 从channel中获取一个数据
        data := <-c
        // 将0视为数据结束
        if data == 0 {
            break
        }
        // 打印数据
        fmt.Println(data)
    }
    // 通知main已经结束循环(我搞定了!)
    c <- 0
}
func main() {
    // 创建一个channel
    c := make(chan int)
    // 并发执行printer, 传入channel
    go printer(c)
    for i := 1; i <= 10; i++ {
        // 将数据通过channel投送给printer
        c <- i
    }
    // 通知并发的printer结束循环(没数据啦!)
    c <- 0
    // 等待printer结束(搞定喊我!)
    <-c
}

运行代码,输出如下:

1
2
3
4
5
6
7
8
9
10

代码说明如下:

  • 第 10 行,创建一个无限循环,只有当第 16 行获取到的数据为 0 时才会退出循环。
  • 第 13 行,从函数参数传入的通道中获取一个整型数值。
  • 第 21 行,打印整型数值。
  • 第 25 行,在退出循环时,通过通道通知 main() 函数已经完成工作。
  • 第 32 行,创建一个整型通道进行跨 goroutine 的通信。
  • 第 35 行,创建一个 goroutine,并发执行 printer() 函数。
  • 第 37 行,构建一个数值循环,将 1~10 的数通过通道传送给 printer 构造出的 goroutine。
  • 第 44 行,给通道传入一个 0,表示将前面的数据处理完成后,退出循环。
  • 第 47 行,在数据发送过去后,因为并发和调度的原因,任务会并发执行。这里需要等待 printer 的第 25 行返回数据后,才可以退出 main()。

本例的设计模式就是典型的生产者和消费者。生产者是第 37 行的循环,而消费者是 printer() 函数。整个例子使用了两个 goroutine,一个是 main(),一个是通过第 35 行 printer() 函数创建的 goroutine。两个 goroutine 通过第 32 行创建的通道进行通信。这个通道有下面两重功能。

  • 数据传送:第 40 行中发送数据和第 13 行接收数据。
  • 控制指令:类似于信号量的功能。同步 goroutine 的操作。功能简单描述为:
    • 第 44 行:“没数据啦!”
    • 第 25 行:“我搞定了!”
    • 第 47 行:“搞定喊我!”

十、Go语言单向通道——通道中的单行道

Go语言的类型系统提供了单方向的 channel 类型,顾名思义,单向 channel 就是只能用于写入或者只能用于读取数据。当然 channel 本身必然是同时支持读写的,否则根本没法用。

假如一个 channel 真的只能读取数据,那么它肯定只会是空的,因为你没机会往里面写数据。同理,如果一个 channel 只允许写入数据,即使写进去了,也没有丝毫意义,因为没有办法读取到里面的数据。所谓的单向 channel 概念,其实只是对 channel 的一种使用限制。

单向通道的声明格式

我们在将一个 channel 变量传递到一个函数时,可以通过将其指定为单向 channel 变量,从而限制该函数中可以对此 channel 的操作,比如只能往这个 channel 中写入数据,或者只能从这个 channel 读取数据。

单向 channel 变量的声明非常简单,只能写入数据的通道类型为chan<-,只能读取数据的通道类型为<-chan,格式如下:

var 通道实例 chan<- 元素类型 // 只能写入数据的通道
var 通道实例 <-chan 元素类型 // 只能读取数据的通道

  • 元素类型:通道包含的元素类型。
  • 通道实例:声明的通道变量。
单向通道的使用例子

示例代码如下:

ch := make(chan int)
// 声明一个只能写入数据的通道类型, 并赋值为ch
var chSendOnly chan<- int = ch
//声明一个只能读取数据的通道类型, 并赋值为ch
var chRecvOnly <-chan int = ch

上面的例子中,chSendOnly 只能写入数据,如果尝试读取数据,将会出现如下报错:

invalid operation: <-chSendOnly (receive from send-only type chan<- int)

同理,chRecvOnly 也是不能写入数据的。

当然,使用 make 创建通道时,也可以创建一个只写入或只读取的通道:

ch := make(<-chan int)
var chReadOnly <-chan int = ch
<-chReadOnly

上面代码编译正常,运行也是正确的。但是,一个不能写入数据只能读取的通道是毫无意义的。

time包中的单向通道

time 包中的计时器会返回一个 timer 实例,代码如下:

timer := time.NewTimer(time.Second)

timer的Timer类型定义如下:

type Timer struct {    C <-chan Time    r runtimeTimer}

第 2 行中 C 通道的类型就是一种只能读取的单向通道。如果此处不进行通道方向约束,一旦外部向通道写入数据,将会造成其他使用到计时器的地方逻辑产生混乱。

因此,单向通道有利于代码接口的严谨性。

关闭 channel

关闭 channel 非常简单,直接使用Go语言内置的 close() 函数即可:

close(ch)

在介绍了如何关闭 channel 之后,我们就多了一个问题:如何判断一个 channel 是否已经被关闭?我们可以在读取的时候使用多重返回值的方式:

x, ok := <-ch

这个用法与 map 中的按键获取 value 的过程比较类似,只需要看第二个 bool 返回值即可,如果返回值是 false 则表示 ch 已经被关闭。

十一、Go语言无缓冲的通道

Go语言中无缓冲的通道(unbuffered channel)是指在接收前没有能力保存任何值的通道。这种类型的通道要求发送 goroutine 和接收 goroutine 同时准备好,才能完成发送和接收操作。

如果两个 goroutine 没有同时准备好,通道会导致先执行发送或接收操作的 goroutine 阻塞等待。这种对通道进行发送和接收的交互行为本身就是同步的。其中任意一个操作都无法离开另一个操作单独存在。

阻塞指的是由于某种原因数据没有到达,当前协程(线程)持续处于等待状态,直到条件满足才解除阻塞。

同步指的是在两个或多个协程(线程)之间,保持数据内容一致性的机制。

下图展示两个 goroutine 如何利用无缓冲的通道来共享一个值。

使用无缓冲的通道在 goroutine 之间同步
图:使用无缓冲的通道在 goroutine 之间同步

在第 1 步,两个 goroutine 都到达通道,但哪个都没有开始执行发送或者接收。在第 2 步,左侧的 goroutine 将它的手伸进了通道,这模拟了向通道发送数据的行为。这时,这个 goroutine 会在通道中被锁住,直到交换完成。

在第 3 步,右侧的 goroutine 将它的手放入通道,这模拟了从通道里接收数据。这个 goroutine 一样也会在通道中被锁住,直到交换完成。在第 4 步和第 5 步,进行交换,并最终在第 6 步,两个 goroutine 都将它们的手从通道里拿出来,这模拟了被锁住的 goroutine 得到释放。两个 goroutine 现在都可以去做别的事情了。

为了讲得更清楚,让我们来看两个完整的例子。这两个例子都会使用无缓冲的通道在两个 goroutine 之间同步交换数据。

【示例 1】在网球比赛中,两位选手会把球在两个人之间来回传递。选手总是处在以下两种状态之一,要么在等待接球,要么将球打向对方。可以使用两个 goroutine 来模拟网球比赛,并使用无缓冲的通道来模拟球的来回,代码如下所示。

// 这个示例程序展示如何用无缓冲的通道来模拟
// 2 个goroutine 间的网球比赛
package main
import (
    "fmt"
    "math/rand"
    "sync"
    "time"
)
// wg 用来等待程序结束
var wg sync.WaitGroup
func init() {
    rand.Seed(time.Now().UnixNano())
}
// main 是所有Go 程序的入口
func main() {
    // 创建一个无缓冲的通道
    court := make(chan int)
    // 计数加 2,表示要等待两个goroutine
    wg.Add(2)
    // 启动两个选手
    go player("Nadal", court)
    go player("Djokovic", court)
    // 发球
    court <- 1
    // 等待游戏结束
    wg.Wait()
}
// player 模拟一个选手在打网球
func player(name string, court chan int) {
    // 在函数退出时调用Done 来通知main 函数工作已经完成
    defer wg.Done()
    for {
        // 等待球被击打过来
        ball, ok := <-court
        if !ok {
            // 如果通道被关闭,我们就赢了
            fmt.Printf("Player %s Won\n", name)
            return
        }
        // 选随机数,然后用这个数来判断我们是否丢球
        n := rand.Intn(100)
        if n%13 == 0 {
            fmt.Printf("Player %s Missed\n", name)
            // 关闭通道,表示我们输了
            close(court)
            return
        }
        // 显示击球数,并将击球数加1
        fmt.Printf("Player %s Hit %d\n", name, ball)
        ball++
        // 将球打向对手
        court <- ball
    }
}

运行这个程序,输出结果如下所示。

Player Nadal Hit 1
Player Djokovic Hit 2
Player Nadal Hit 3
Player Djokovic Missed
Player Nadal Won

代码说明如下:

  • 第 22 行,创建了一个 int 类型的无缓冲的通道,让两个 goroutine 在击球时能够互相同步。
  • 第 28 行和第 29 行,创建了参与比赛的两个 goroutine。在这个时候,两个 goroutine 都阻塞住等待击球。
  • 第 32 行,将球发到通道里,程序开始执行这个比赛,直到某个 goroutine 输掉比赛。
  • 第 43 行可以找到一个无限循环的 for 语句。在这个循环里,是玩游戏的过程。
  • 第 45 行,goroutine 从通道接收数据,用来表示等待接球。这个接收动作会锁住 goroutine,直到有数据发送到通道里。通道的接收动作返回时。
  • 第 46 行会检测 ok 标志是否为 false。如果这个值是 false,表示通道已经被关闭,游戏结束。
  • 第 53 行到第 60 行,会产生一个随机数,用来决定 goroutine 是否击中了球。
  • 第 58 行如果某个 goroutine 没有打中球,关闭通道。之后两个 goroutine 都会返回,通过 defer 声明的 Done 会被执行,程序终止。
  • 第 64 行,如果击中了球 ball 的值会递增 1,并在第 67 行,将 ball 作为球重新放入通道,发送给另一位选手。在这个时刻,两个 goroutine 都会被锁住,直到交换完成。

【示例 2】用不同的模式,使用无缓冲的通道,在 goroutine 之间同步数据,来模拟接力比赛。在接力比赛里,4 个跑步者围绕赛道轮流跑。第二个、第三个和第四个跑步者要接到前一位跑步者的接力棒后才能起跑。比赛中最重要的部分是要传递接力棒,要求同步传递。在同步接力棒的时候,参与接力的两个跑步者必须在同一时刻准备好交接。代码如下所示。

// 这个示例程序展示如何用无缓冲的通道来模拟
// 4 个goroutine 间的接力比赛
package main
import (
    "fmt"
    "sync"
    "time"
)
// wg 用来等待程序结束
var wg sync.WaitGroup
// main 是所有Go 程序的入口
func main() {
    // 创建一个无缓冲的通道
    baton := make(chan int)
    // 为最后一位跑步者将计数加1
    wg.Add(1)
    // 第一位跑步者持有接力棒
    go Runner(baton)
    // 开始比赛
    baton <- 1
    // 等待比赛结束
    wg.Wait()
}
// Runner 模拟接力比赛中的一位跑步者
func Runner(baton chan int) {
    var newRunner int
    // 等待接力棒
    runner := <-baton
    // 开始绕着跑道跑步
    fmt.Printf("Runner %d Running With Baton\n", runner)
    // 创建下一位跑步者
    if runner != 4 {
        newRunner = runner + 1
        fmt.Printf("Runner %d To The Line\n", newRunner)
        go Runner(baton)
    }
    // 围绕跑道跑
    time.Sleep(100 \* time.Millisecond)
    // 比赛结束了吗?
    if runner == 4 {
        fmt.Printf("Runner %d Finished, Race Over\n", runner)
        wg.Done()
        return
    }
    // 将接力棒交给下一位跑步者
    fmt.Printf("Runner %d Exchange With Runner %d\n",
        runner,
        newRunner)
    baton <- newRunner
}

运行这个程序,输出结果如下所示。

Runner 1 Running With Baton
Runner 1 To The Line
Runner 1 Exchange With Runner 2
Runner 2 Running With Baton
Runner 2 To The Line
Runner 2 Exchange With Runner 3
Runner 3 Running With Baton
Runner 3 To The Line
Runner 3 Exchange With Runner 4
Runner 4 Running With Baton
Runner 4 Finished, Race Over

代码说明如下:

  • 第 17 行,创建了一个无缓冲的 int 类型的通道 baton,用来同步传递接力棒。
  • 第 20 行,我们给 WaitGroup 加 1,这样 main 函数就会等最后一位跑步者跑步结束。
  • 第 23 行创建了一个 goroutine,用来表示第一位跑步者来到跑道。
  • 第 26 行,将接力棒交给这个跑步者,比赛开始。
  • 第 29 行,main 函数阻塞在 WaitGroup,等候最后一位跑步者完成比赛。
  • 第 37 行,goroutine 对 baton 通道执行接收操作,表示等候接力棒。
  • 第 46 行,一旦接力棒传了进来,就会创建一位新跑步者,准备接力下一棒,直到 goroutine 是第四个跑步者。
  • 第 50 行,跑步者围绕跑道跑 100 ms。
  • 第 55 行,如果第四个跑步者完成了比赛,就调用 Done,将 WaitGroup 减 1,之后 goroutine 返回。
  • 第 64 行,如果这个 goroutine 不是第四个跑步者,接力棒会交到下一个已经在等待的跑步者手上。在这个时候,goroutine 会被锁住,直到交接完成。

在这两个例子里,我们使用无缓冲的通道同步 goroutine,模拟了网球和接力赛。代码的流程与这两个活动在真实世界中的流程完全一样,这样的代码很容易读懂。

现在知道了无缓冲的通道是如何工作的,下一节我们将为大家介绍带缓冲的通道。

十二、Go语言带缓冲的通道

Go语言中有缓冲的通道(buffered channel)是一种在被接收前能存储一个或者多个值的通道。这种类型的通道并不强制要求 goroutine 之间必须同时完成发送和接收。通道会阻塞发送和接收动作的条件也会不同。只有在通道中没有要接收的值时,接收动作才会阻塞。只有在通道没有可用缓冲区容纳被发送的值时,发送动作才会阻塞。

这导致有缓冲的通道和无缓冲的通道之间的一个很大的不同:无缓冲的通道保证进行发送和接收的 goroutine 会在同一时间进行数据交换;有缓冲的通道没有这种保证。

在无缓冲通道的基础上,为通道增加一个有限大小的存储空间形成带缓冲通道。带缓冲通道在发送时无需等待接收方接收即可完成发送过程,并且不会发生阻塞,只有当存储空间满时才会发生阻塞。同理,如果缓冲通道中有数据,接收时将不会发生阻塞,直到通道中没有数据可读时,通道将会再度阻塞。

无缓冲通道保证收发过程同步。无缓冲收发过程类似于快递员给你电话让你下楼取快递,整个递交快递的过程是同步发生的,你和快递员不见不散。但这样做快递员就必须等待所有人下楼完成操作后才能完成所有投递工作。如果快递员将快递放入快递柜中,并通知用户来取,快递员和用户就成了异步收发过程,效率可以有明显的提升。带缓冲的通道就是这样的一个“快递柜”。

创建带缓冲通道

如何创建带缓冲的通道呢?参见如下代码:

通道实例 := make(chan 通道类型, 缓冲大小)

  • 通道类型:和无缓冲通道用法一致,影响通道发送和接收的数据类型。
  • 缓冲大小:决定通道最多可以保存的元素数量。
  • 通道实例:被创建出的通道实例。

下面通过一个例子中来理解带缓冲通道的用法,参见下面的代码:

package main
import "fmt"
func main() {
    // 创建一个3个元素缓冲大小的整型通道
    ch := make(chan int, 3)
    // 查看当前通道的大小
    fmt.Println(len(ch))
    // 发送3个整型元素到通道
    ch <- 1
    ch <- 2
    ch <- 3
    // 查看当前通道的大小
    fmt.Println(len(ch))
}

代码输出如下:

0
3

代码说明如下:

  • 第 8 行,创建一个带有 3 个元素缓冲大小的整型类型的通道。
  • 第 11 行,查看当前通道的大小。带缓冲的通道在创建完成时,内部的元素是空的,因此使用 len() 获取到的返回值为 0。
  • 第 14~16 行,发送 3 个整型元素到通道。因为使用了缓冲通道。即便没有 goroutine 接收,发送者也不会发生阻塞。
  • 第 19 行,由于填充了 3 个通道,此时的通道长度变为 3。
阻塞条件

带缓冲通道在很多特性上和无缓冲通道是类似的。无缓冲通道可以看作是长度永远为 0 的带缓冲通道。因此根据这个特性,带缓冲通道在下面列举的情况下依然会发生阻塞:

  • 带缓冲通道被填满时,尝试再次发送数据时发生阻塞。
  • 带缓冲通道为空时,尝试接收数据时发生阻塞。
为什么Go语言对通道要限制长度而不提供无限长度的通道?

我们知道通道(channel)是在两个 goroutine 间通信的桥梁。使用 goroutine 的代码必然有一方提供数据,一方消费数据。当提供数据一方的数据供给速度大于消费方的数据处理速度时,如果通道不限制长度,那么内存将不断膨胀直到应用崩溃。因此,限制通道的长度有利于约束数据提供方的供给速度,供给数据量必须在消费方处理量+通道长度的范围内,才能正常地处理数据。

十三、Go语言channel超时机制

Go语言没有提供直接的超时处理机制,所谓超时可以理解为当我们上网浏览一些网站时,如果一段时间之后不作操作,就需要重新登录。

那么我们应该如何实现这一功能呢,这时就可以使用 select 来设置超时。

虽然 select 机制不是专门为超时而设计的,却能很方便的解决超时问题,因为 select 的特点是只要其中有一个 case 已经完成,程序就会继续往下执行,而不会考虑其他 case 的情况。

超时机制本身虽然也会带来一些问题,比如在运行比较快的机器或者高速的网络上运行正常的程序,到了慢速的机器或者网络上运行就会出问题,从而出现结果不一致的现象,但从根本上来说,解决死锁问题的价值要远大于所带来的问题。

select 的用法与 switch 语言非常类似,由 select 开始一个新的选择块,每个选择条件由 case 语句来描述。

与 switch 语句相比,select 有比较多的限制,其中最大的一条限制就是每个 case 语句里必须是一个 IO 操作,大致的结构如下:

select {
case <-chan1:
// 如果chan1成功读到数据,则进行该case处理语句
case chan2 <- 1:
// 如果成功向chan2写入数据,则进行该case处理语句
default:
// 如果上面都没有成功,则进入default处理流程
}

在一个 select 语句中,Go语言会按顺序从头至尾评估每一个发送和接收的语句。

如果其中的任意一语句可以继续执行(即没有被阻塞),那么就从那些可以执行的语句中任意选择一条来使用。

如果没有任意一条语句可以执行(即所有的通道都被阻塞),那么有如下两种可能的情况:

  • 如果给出了 default 语句,那么就会执行 default 语句,同时程序的执行会从 select 语句后的语句中恢复;
  • 如果没有 default 语句,那么 select 语句将被阻塞,直到至少有一个通信可以进行下去。

示例代码如下所示:

package main
import (
    "fmt"
    "time"
)
func main() {
    ch := make(chan int)
    quit := make(chan bool)
    //新开一个协程
    go func() {
        for {
            select {
            case num := <-ch:
                fmt.Println("num = ", num)
            case <-time.After(3 \* time.Second):
                fmt.Println("超时")
                quit <- true
            }
        }
    }() //别忘了()
    for i := 0; i < 5; i++ {
        ch <- i
        time.Sleep(time.Second)
    }
    <-quit
    fmt.Println("程序结束")
}

运行结果如下:

num = 0
num = 1
num = 2
num = 3
num = 4
超时
程序结束

十四、Go语言通道的多路复用——同时处理接收和发送多个通道的数据

多路复用是通信和网络中的一个专业术语。多路复用通常表示在一个信道上传输多路信号或数据流的过程和技术。

提示

报话机同一时刻只能有一边进行收或者发的单边通信,报话机需要遵守的通信流程如下:

  • 说话方在完成时需要补上一句“完毕”,随后放开通话按钮,从发送切换到接收状态,收听对方说话。
  • 收听方在听到对方说“完毕”时,按下通话按钮,从接收切换到发送状态,开始说话。

电话可以在说话的同时听到对方说话,所以电话是一种多路复用的设备,一条通信线路上可以同时接收或者发送数据。同样的,网线、光纤也都是基于多路复用模式来设计的,网线、光纤不仅可支持同时收发数据,还支持多个人同时收发数据。

在使用通道时,想同时接收多个通道的数据是一件困难的事情。通道在接收数据时,如果没有数据可以接收将会发生阻塞。虽然可以使用如下模式进行遍历,但运行性能会非常差。

for{
    // 尝试接收ch1通道
    data, ok := <-ch1
    // 尝试接收ch2通道
    data, ok := <-ch2
    // 接收后续通道
    …
}

Go语言中提供了 select 关键字,可以同时响应多个通道的操作。select 的用法与 switch 语句非常类似,由 select 开始一个新的选择块,每个选择条件由 case 语句来描述。

与 switch 语句可以选择任何可使用相等比较的条件相比,select 有比较多的限制,其中最大的一条限制就是每个 case 语句里必须是一个 IO 操作,大致结构如下:

select{
case 操作1:
响应操作1
case 操作2:
响应操作2

default:
没有操作情况
}

  • 操作1、操作2:包含通道收发语句,请参考下表。
操 作语句示例
接收任意数据case <- ch;
接收变量case d := <- ch;
发送数据case ch <- 100;
  • 响应操作1、响应操作2:当操作发生时,会执行对应 case 的响应操作。
  • default:当没有任何操作时,默认执行 default 中的语句。

可以看出,select 不像 switch,后面并不带判断条件,而是直接去查看 case 语句。每个 case 语句都必须是一个面向 channel 的操作。

基于此功能,我们可以实现一个有趣的程序:

ch := make(chan int, 1)
for {
    select {
        case ch <- 0:
        case ch <- 1:
    }
    i := <-ch
    fmt.Println("Value received:", i)
}

能看明白这段代码的含义吗?其实很简单,这个程序实现了一个随机向 ch 中写入一个 0 或者 1 的过程。当然,这是个死循环。关于 select 的详细使用方法,请参考下节的示例。

十五、Go语言RPC(模拟远程过程调用)

服务器开发中会使用RPC(Remote Procedure Call,远程过程调用)简化进程间通信的过程。RPC 能有效地封装通信过程,让远程的数据收发通信过程看起来就像本地的函数调用一样。

本例中,使用通道代替 Socket 实现 RPC 的过程。客户端与服务器运行在同一个进程,服务器和客户端在两个 goroutine 中运行。

我们先给出完整代码,然后再详细分析每一个部分。

package main
import (
    "errors"
    "fmt"
    "time"
)
// 模拟RPC客户端的请求和接收消息封装
func RPCClient(ch chan string, req string) (string, error) {
    // 向服务器发送请求
    ch <- req
    // 等待服务器返回
    select {
    case ack := <-ch: // 接收到服务器返回数据
        return ack, nil
    case <-time.After(time.Second): // 超时
        return "", errors.New("Time out")
    }
}
// 模拟RPC服务器端接收客户端请求和回应
func RPCServer(ch chan string) {
    for {
        // 接收客户端请求
        data := <-ch
        // 打印接收到的数据
        fmt.Println("server received:", data)
        // 反馈给客户端收到
        ch <- "roger"
    }
}
func main() {
    // 创建一个无缓冲字符串通道
    ch := make(chan string)
    // 并发执行服务器逻辑
    go RPCServer(ch)
    // 客户端请求数据和接收数据
    recv, err := RPCClient(ch, "hi")
    if err != nil {
        // 发生错误打印
        fmt.Println(err)
    } else {
        // 正常接收到数据
        fmt.Println("client received", recv)
    }
}

客户端请求和接收封装

下面的代码封装了向服务器请求数据,等待服务器返回数据,如果请求方超时,该函数还会处理超时逻辑。

模拟 RPC 的代码:

// 模拟RPC客户端的请求和接收消息封装
func RPCClient(ch chan string, req string) (string, error) {
    // 向服务器发送请求
    ch <- req
    // 等待服务器返回
    select {
    case ack := <-ch:  // 接收到服务器返回数据
        return ack, nil
    case <-time.After(time.Second):  // 超时
        return "", errors.New("Time out")
    }
}

代码说明如下:

  • 第 5 行,模拟 socket 向服务器发送一个字符串信息。服务器接收后,结束阻塞执行下一行。
  • 第 8 行,使用 select 开始做多路复用。注意,select 虽然在写法上和 switch 一样,都可以拥有 case 和 default。但是 select 关键字后面不接任何语句,而是将要复用的多个通道语句写在每一个 case 上,如第 9 行和第 11 行所示。
  • 第 11 行,使用了 time 包提供的函数 After(),从字面意思看就是多少时间之后,其参数是 time 包的一个常量,time.Second 表示 1 秒。time.After 返回一个通道,这个通道在指定时间后,通过通道返回当前时间。
  • 第 12 行,在超时时,返回超时错误。

RPCClient() 函数中,执行到 select 语句时,第 9 行和第 11 行的通道操作会同时开启。如果第 9 行的通道先返回,则执行第 10 行逻辑,表示正常接收到服务器数据;如果第 11 行的通道先返回,则执行第 12 行的逻辑,表示请求超时,返回错误。

服务器接收和反馈数据

服务器接收到客户端的任意数据后,先打印再通过通道返回给客户端一个固定字符串,表示服务器已经收到请求。

// 模拟RPC服务器端接收客户端请求和回应
func RPCServer(ch chan string) {
    for {
        // 接收客户端请求
        data := <-ch
        // 打印接收到的数据
        fmt.Println("server received:", data)
        //向客户端反馈已收到
        ch <- "roger"
    }
}

代码说明如下:

  • 第 3 行,构造出一个无限循环。服务器处理完客户端请求后,通过无限循环继续处理下一个客户端请求。
  • 第 5 行,通过字符串通道接收一个客户端的请求。
  • 第 8 行,将接收到的数据打印出来。
  • 第 11 行,给客户端反馈一个字符串。

运行整个程序,客户端可以正确收到服务器返回的数据,客户端 RPCClient() 函数的代码按下面代码中加粗部分的分支执行。

// 等待服务器返回
select {
case ack := <-ch:  // 接收到服务器返回数据
    return ack, nil
case <-time.After(time.Second):  // 超时
    return "", errors.New("Time out")
}

程序输出如下:

server received: hi
client received roger

模拟超时

上面的例子虽然有客户端超时处理,但是永远不会触发,因为服务器的处理速度很快,也没有真正的网络延时或者“服务器宕机”的情况。因此,为了展示 select 中超时的处理,在服务器逻辑中增加一条语句,故意让服务器延时处理一段时间,造成客户端请求超时,代码如下:

// 模拟RPC服务器端接收客户端请求和回应
func RPCServer(ch chan string) {
    for {
        // 接收客户端请求
        data := <-ch
        // 打印接收到的数据
        fmt.Println("server received:", data)
        // 通过睡眠函数让程序执行阻塞2秒的任务
        time.Sleep(time.Second \* 2)
        // 反馈给客户端收到
        ch <- "roger"
    }
}

第 11 行中,time.Sleep() 函数会让 goroutine 执行暂停 2 秒。使用这种方法模拟服务器延时,造成客户端超时。客户端处理超时 1 秒时通道就会返回:

// 等待服务器返回
select {
case ack := <-ch:  // 接收到服务器返回数据
    return ack, nil
case <-time.After(time.Second):  // 超时
    return "", errors.New("Time out")
}

上面代码中,加黑部分的代码就会被执行。

主流程

主流程中会创建一个无缓冲的字符串格式通道。将通道传给服务器的 RPCServer() 函数,这个函数并发执行。使用 RPCClient() 函数通过 ch 对服务器发出 RPC 请求,同时接收服务器反馈数据或者等待超时。参考下面代码:

func main() {
    // 创建一个无缓冲字符串通道
    ch := make(chan string)
    // 并发执行服务器逻辑
    go RPCServer(ch)
    // 客户端请求数据和接收数据
    recv, err := RPCClient(ch, "hi")
    if err != nil {
            // 发生错误打印
        fmt.Println(err)
    } else {
            // 正常接收到数据
        fmt.Println("client received", recv)
    }
}

代码说明如下:

  • 第 4 行,创建无缓冲的字符串通道,这个通道用于模拟网络和 socke t概念,既可以从通道接收数据,也可以发送。
  • 第 7 行,并发执行服务器逻辑。服务器一般都是独立进程的,这里使用并发将服务器和客户端逻辑同时在一个进程内运行。
  • 第 10 行,使用 RPCClient() 函数,发送“hi”给服务器,同步等待服务器返回。
  • 第 13 行,如果通信过程发生错误,打印错误。
  • 第 16 行,正常接收时,打印收到的数据。

十六、Go语言使用通道响应计时器的事件

Go语言中的 time 包提供了计时器的封装。由于 Go语言中的通道和 goroutine 的设计,定时任务可以在 goroutine 中通过同步的方式完成,也可以通过在 goroutine 中异步回调完成。这里将分两种用法进行例子展示。

一段时间之后(time.After)

延迟回调:

package main
import (
    "fmt"
    "time"
)
func main() {
    // 声明一个退出用的通道
    exit := make(chan int)
    // 打印开始
    fmt.Println("start")
    // 过1秒后, 调用匿名函数
    time.AfterFunc(time.Second, func() {
        // 1秒后, 打印结果
        fmt.Println("one second after")
        // 通知main()的goroutine已经结束
        exit <- 0
    })
    // 等待结束
    <-exit
}

代码说明如下:

  • 第 10 行,声明一个退出用的通道,往这个通道里写数据表示退出。
  • 第 16 行,调用 time.AfterFunc() 函数,传入等待的时间和一个回调。回调使用一个匿名函数,在时间到达后,匿名函数会在另外一个 goroutine 中被调用。
  • 第 22 行,任务完成后,往退出通道中写入数值表示需要退出。
  • 第 26 行,运行到此处时持续阻塞,直到 1 秒后第 22 行被执行后结束阻塞。

time.AfterFunc() 函数是在 time.After 基础上增加了到时的回调,方便使用。

而 time.After() 函数又是在 time.NewTimer() 函数上进行的封装,下面的例子展示如何使用 timer.NewTimer() 和 time.NewTicker()。

定点计时

计时器(Timer)的原理和倒计时闹钟类似,都是给定多少时间后触发。打点器(Ticker)的原理和钟表类似,钟表每到整点就会触发。这两种方法创建后会返回 time.Ticker 对象和 time.Timer 对象,里面通过一个 C 成员,类型是只能接收的时间通道(<-chan Time),使用这个通道就可以获得时间触发的通知。

下面代码创建一个打点器,每 500 毫秒触发一起;创建一个计时器,2 秒后触发,只触发一次。

计时器:

package main
import (
    "fmt"
    "time"
)
func main() {
    // 创建一个打点器, 每500毫秒触发一次
    ticker := time.NewTicker(time.Millisecond \* 500)
    // 创建一个计时器, 2秒后触发
    stopper := time.NewTimer(time.Second \* 2)
    // 声明计数变量
    var i int
    // 不断地检查通道情况
    for {
        // 多路复用通道
        select {
        case <-stopper.C:  // 计时器到时了
            fmt.Println("stop")
            // 跳出循环
            goto StopHere
        case <-ticker.C:  // 打点器触发了
            // 记录触发了多少次
            i++
            fmt.Println("tick", i)
        }
    }
// 退出的标签, 使用goto跳转
StopHere:
    fmt.Println("done")
}

代码说明如下:

  • 第 11 行,创建一个打点器,500 毫秒触发一次,返回 *time.Ticker 类型变量。
  • 第 14 行,创建一个计时器,2 秒后返回,返回 *time.Timer 类型变量。
  • 第 17 行,声明一个变量,用于累计打点器触发次数。
  • 第 20 行,每次触发后,select 会结束,需要使用循环再次从打点器返回的通道中获取触发通知。
  • 第 23 行,同时等待多路计时器信号。
  • 第 24 行,计时器信号到了。
  • 第 29 行,通过 goto 跳出循环。
  • 第 31 行,打点器信号到了,通过i自加记录触发次数并打印。

十七、Go语言多核并行化

Go语言具有支持高并发的特性,可以很方便地实现多线程运算,充分利用多核心 cpu 的性能。

众所周知服务器的处理器大都是单核频率较低而核心数较多,对于支持高并发的程序语言,可以充分利用服务器的多核优势,从而降低单核压力,减少性能浪费。

Go语言实现多核多线程并发运行是非常方便的,下面举个例子:

package main
import (
    "fmt"
)
func main() {
    for i := 0; i < 5; i++ {
        go AsyncFunc(i)
    }
}
func AsyncFunc(index int) {
    sum := 0
    for i := 0; i < 10000; i++ {
        sum += 1
    }
    fmt.Printf("线程%d, sum为:%d\n", index, sum)
}

运行结果如下:

线程0, sum为:10000
线程2, sum为:10000
线程3, sum为:10000
线程1, sum为:10000
线程4, sum为:10000

在执行一些昂贵的计算任务时,我们希望能够尽量利用现代服务器普遍具备的多核特性来尽量将任务并行化,从而达到降低总计算时间的目的。此时我们需要了解 CPU 核心的数量,并针对性地分解计算任务到多个 goroutine 中去并行运行。

下面我们来模拟一个完全可以并行的计算任务:计算 N 个整型数的总和。我们可以将所有整型数分成 M 份,M 即 CPU 的个数。让每个 CPU 开始计算分给它的那份计算任务,最后将每个 CPU 的计算结果再做一次累加,这样就可以得到所有 N 个整型数的总和:

type Vector []float64
// 分配给每个CPU的计算任务
func (v Vector) DoSome(i, n int, u Vector, c chan int) {
    for ; i < n; i++ {
        v[i] += u.Op(v[i])
    }
    c <- 1 // 发信号告诉任务管理者我已经计算完成了
}
const NCPU = 16 // 假设总共有16核
func (v Vector) DoAll(u Vector) {
    c := make(chan int, NCPU) // 用于接收每个CPU的任务完成信号
    for i := 0; i < NCPU; i++ {
        go v.DoSome(i\*len(v)/NCPU, (i+1)\*len(v)/NCPU, u, c)
    }
    // 等待所有CPU的任务完成
    for i := 0; i < NCPU; i++ {
        <-c // 获取到一个数据,表示一个CPU计算完成了
    }
    // 到这里表示所有计算已经结束
}

这两个函数看起来设计非常合理,其中 DoAll() 会根据 CPU 核心的数目对任务进行分割,然后开辟多个 goroutine 来并行执行这些计算任务。

是否可以将总的计算时间降到接近原来的 1/N 呢?答案是不一定。如果掐秒表,会发现总的执行时间没有明显缩短。再去观察 CPU 运行状态,你会发现尽管我们有 16 个 CPU 核心,但在计算过程中其实只有一个 CPU 核心处于繁忙状态,这是会让很多Go语言初学者迷惑的问题。

官方给出的答案是,这是当前版本的 Go 编译器还不能很智能地去发现和利用多核的优势。虽然我们确实创建了多个 goroutine,并且从运行状态看这些 goroutine 也都在并行运行,但实际上所有这些 goroutine 都运行在同一个 CPU 核心上,在一个 goroutine 得到时间片执行的时候,其他 goroutine 都会处于等待状态。从这一点可以看出,虽然 goroutine 简化了我们写并行代码的过程,但实际上整体运行效率并不真正高于单线程程序。

虽然Go语言还不能很好的利用多核心的优势,我们可以先通过设置环境变量 GOMAXPROCS 的值来控制使用多少个 CPU 核心。具体操作方法是通过直接设置环境变量 GOMAXPROCS 的值,或者在代码中启动 goroutine 之前先调用以下这个语句以设置使用 16 个 CPU 核心:

runtime.GOMAXPROCS(16)

到底应该设置多少个 CPU 核心呢,其实 runtime 包中还提供了另外一个 NumCPU() 函数来获取核心数,示例代码如下:

package main
import (
"fmt"
"runtime"
)
func main() {
cpuNum := runtime.NumCPU() //获得当前设备的cpu核心数
fmt.Println("cpu核心数:", cpuNum)
runtime.GOMAXPROCS(cpuNum) //设置需要用到的cpu数量
}

运行结果如下:

cpu核心数: 4

十八、Go语言Telnet回音服务器——TCP服务器的基本结构

Telnet 协议是 TCP/IP 协议族中的一种。它允许用户(Telnet 客户端)通过一个协商过程与一个远程设备进行通信。本例将使用一部分 Telnet 协议与服务器进行通信。

服务器的网络库为了完整展示自己的代码实现了完整的收发过程,一般比较倾向于使用发送任意封包返回原数据的逻辑。这个过程类似于对着大山高喊,大山把你的声音原样返回的过程。也就是回音(Echo)。本节使用 Go语言中的 Socket、goroutine 和通道编写一个简单的 Telnet 协议的回音服务器。

回音服务器的代码分为 4 个部分,分别是接受连接、会话处理、Telnet 命令处理和程序入口。

接受连接

回音服务器能同时服务于多个连接。要接受连接就需要先创建侦听器,侦听器需要一个侦听地址和协议类型。就像你想卖东西,需要先确认卖什么东西,卖东西的类型就是协议类型,然后需要一个店面,店面位于街区的某个位置,这就是侦听器的地址。一个服务器可以开启多个侦听器,就像一个街区可以有多个店面。街区上的编号对应的就是地址中的端口号,如下图所示。

img
图:IP和端口号

  • 主机 IP:一般为一个 IP 地址或者域名,127.0.0.1 表示本机地址。
  • 端口号:16 位无符号整型值,一共有 65536 个有效端口号。

通过地址和协议名创建侦听器后,可以使用侦听器响应客户端连接。响应连接是一个不断循环的过程,就像到银行办理业务时,一般是排队处理,前一个人办理完后,轮到下一个人办理。

我们把每个客户端连接处理业务的过程叫做会话。在会话中处理的操作和接受连接的业务并不冲突可以同时进行。就像银行有 3 个窗口,喊号器会将用户分配到不同的柜台。这里的喊号器就是 Accept 操作,窗口的数量就是 CPU 的处理能力。因此,使用 goroutine 可以轻松实现会话处理和接受连接的并发执行。

如下图清晰地展现了这一过程。

img
图:Socket 处理过程

Go语言中可以根据实际会话数量创建多个 goroutine,并自动的调度它们的处理。

telnet 服务器处理:

package main
import (
    "fmt"
    "net"
)
// 服务逻辑, 传入地址和退出的通道
func server(address string, exitChan chan int) {
    // 根据给定地址进行侦听
    l, err := net.Listen("tcp", address)
    // 如果侦听发生错误, 打印错误并退出
    if err != nil {
        fmt.Println(err.Error())
        exitChan <- 1
    }
    // 打印侦听地址, 表示侦听成功
    fmt.Println("listen: " + address)
    // 延迟关闭侦听器
    defer l.Close()
    // 侦听循环
    for {
        // 新连接没有到来时, Accept是阻塞的
        conn, err := l.Accept()
        // 发生任何的侦听错误, 打印错误并退出服务器
        if err != nil {
            fmt.Println(err.Error())
            continue
        }
        // 根据连接开启会话, 这个过程需要并行执行
        go handleSession(conn, exitChan)
    }
}

代码说明如下:

  • 第 9 行,接受连接的入口,address 为传入的地址,退出服务器使用 exitChan 的通道控制。往 exitChan 写入一个整型值时,进程将以整型值作为程序返回值来结束服务器。
  • 第 12 行,使用 net 包的 Listen() 函数进行侦听。这个函数需要提供两个参数,第一个参数为协议类型,本例需要做的是 TCP 连接,因此填入“tcp”;address 为地址,格式为“主机:端口号”。
  • 第 15 行,如果侦听发生错误,通过第 17 行,往 exitChan 中写入非 0 值结束服务器,同时打印侦听错误。
  • 第 24 行,使用 defer,将侦听器的结束延迟调用。
  • 第 27 行,侦听开始后,开始进行连接接受,每次接受连接后需要继续接受新的连接,周而复始。
  • 第 30 行,服务器接受了一个连接。在没有连接时,Accept() 函数调用后会一直阻塞。连接到来时,返回 conn 和错误变量,conn 的类型是 *tcp.Conn。
  • 第 33 行,某些情况下,连接接受会发生错误,不影响服务器逻辑,这时重新进行新连接接受。
  • 第 39 行,每个连接会生成一个会话。这个会话的处理与接受逻辑需要并行执行,彼此不干扰。
会话处理

每个连接的会话就是一个接收数据的循环。当没有数据时,调用 reader.ReadString 会发生阻塞,等待数据的到来。一旦数据到来,就可以进行各种逻辑处理。

回音服务器的基本逻辑是“收到什么返回什么”,reader.ReadString 可以一直读取 Socket 连接中的数据直到碰到期望的结尾符。这种期望的结尾符也叫定界符,一般用于将 TCP 封包中的逻辑数据拆分开。下例中使用的定界符是回车换行符(“\r\n”),HTTP 协议也是使用同样的定界符。使用 reader.ReadString() 函数可以将封包简单地拆分开。

如下图所示为 Telnet 数据处理过程。

img
图:Telnet 数据处理过程

回音服务器需要将收到的有效数据通过 Socket 发送回去。

Telnet会话处理:

package main
import (
    "bufio"
    "fmt"
    "net"
    "strings"
)
// 连接的会话逻辑
func handleSession(conn net.Conn, exitChan chan int) {
    fmt.Println("Session started:")
    // 创建一个网络连接数据的读取器
    reader := bufio.NewReader(conn)
    // 接收数据的循环
    for {
        // 读取字符串, 直到碰到回车返回
        str, err := reader.ReadString('\n')
        // 数据读取正确
        if err == nil {
            // 去掉字符串尾部的回车
            str = strings.TrimSpace(str)
            // 处理Telnet指令
            if !processTelnetCommand(str, exitChan) {
                conn.Close()
                break
            }
            // Echo逻辑, 发什么数据, 原样返回
            conn.Write([]byte(str + "\r\n"))
        } else {
            // 发生错误
            fmt.Println("Session closed")
            conn.Close()
            break
        }
    }
}

代码说明如下:

  • 第 11 行是会话入口,传入连接和退出用的通道。handle Session() 函数被并发执行。
  • 第 16 行,使用 bufio 包的 NewReader() 方法,创建一个网络数据读取器,这个 Reader 将输入数据的读取过程进行封装,方便我们迅速获取到需要的数据。
  • 第 19 行,会话处理开始时,从 Socket 连接,通过 reader 读取器读取封包,处理封包后需要继续读取从网络发送过来的下一个封包,因此需要一个会话处理循环。
  • 第 22 行,使用 reader.ReadString() 方法进行封包读取。内部会自动处理粘包过程,直到下一个回车符到达后返回数据。这里认为封包来自 Telnet,每个指令以回车换行符(“\r\n”)结尾。
  • 第 25 行,数据读取正常时,返回 err 为 nil。如果发生连接断开、接收错误等网络错误时,err 就不是 nil 了。
  • 第 28 行,reader.ReadString 读取返回的字符串尾部带有回车符,使用 strings.TrimSpace() 函数将尾部带的回车和空白符去掉。
  • 第 31 行,将 str 字符串传入 Telnet 指令处理函数 processTelnetCommand() 中,同时传入退出控制通道 exitChan。当这个函数返回 false 时,表示需要关闭当前连接。
  • 第 32 行和第 33 行,关闭当前连接并退出会话接受循环。
  • 第 37 行,将有效数据通过 conn 的 Write() 方法写入,同时在字符串尾部添加回车换行符(“\r\n”),数据将被 Socket 发送给连接方。
  • 第 41~43 行,处理当 reader.ReadString() 函数返回错误时,打印错误信息并关闭连接,退出会话并接收循环。
Telnet命令处理

Telnet 是一种协议。在操作系统中可以在命令行使用 Telnet 命令发起 TCP 连接。我们一般用 Telnet 来连接 TCP 服务器,键盘输入一行字符回车后,即被发送到服务器上。

在下例中,定义了以下两个特殊控制指令,用以实现一些功能:

  • 输入“@close”退出当前连接会话。
  • 输入“@shutdown”终止服务器运行。

Telnet命令处理:

package main
import (
    "fmt"
    "strings"
)
func processTelnetCommand(str string, exitChan chan int) bool {
    // @close指令表示终止本次会话
    if strings.HasPrefix(str, "@close") {
        fmt.Println("Session closed")
        // 告诉外部需要断开连接
        return false
        // @shutdown指令表示终止服务进程
    } else if strings.HasPrefix(str, "@shutdown") {
        fmt.Println("Server shutdown")
        // 往通道中写入0, 阻塞等待接收方处理
        exitChan <- 0
        // 告诉外部需要断开连接
        return false
    }
    // 打印输入的字符串
    fmt.Println(str)
    return true
}

代码说明如下:

  • 第 8 行,处理 Telnet 命令的函数入口,传入有效字符并退出通道。
  • 第 11~16 行,当输入字符串中包含“@close”前缀时,在第 16 行返回 false,表示需要关闭当前会话。
  • 第 19~27 行,当输入字符串中包含“@shutdown”前缀时,第 24 行将 0 写入 exitChan,表示结束服务器。
  • 第 31 行,没有特殊的控制字符时,打印输入的字符串。
程序入口

Telnet 回音处理主流程:

package main
import (
    "os"
)
func main() {
    // 创建一个程序结束码的通道
    exitChan := make(chan int)
    // 将服务器并发运行
    go server("127.0.0.1:7001", exitChan)
    // 通道阻塞, 等待接收返回值
    code := <-exitChan
    // 标记程序返回值并退出
    os.Exit(code)
}

代码说明如下:

  • 第 10 行,创建一个整型的无缓冲通道作为退出信号。
  • 第 13 行,接受连接的过程可以并发操作,使用 go 将 server() 函数开启 goroutine。
  • 第 16 行,从 exitChan 中取出返回值。如果取不到数据就一直阻塞。
  • 第 19 行,将程序返回值传入 os.Exit() 函数中并终止程序。

编译所有代码并运行,命令行提示如下:

listen: 127.0.0.1:7001

此时,Socket 侦听成功。在操作系统中的命令行中输入:

telnet 127.0.0.1 7001

尝试连接本地的 7001 端口。接下来进入测试服务器的流程。

测试输入字符串

在 Telnet 连接后,输入字符串 hello,Telnet 命令行显示如下:

$ telnet 127.0.0.1 7001
Trying 127.0.0.1…
Connected to 127.0.0.1.
Escape character is ‘^]’.
hello
hello

服务器显示如下:

listen: 127.0.0.1:7001
Session started:
hello

客户端输入的字符串会在服务器中显示,同时客户端也会收到自己发给服务器的内容,这就是一次回音。

测试关闭会话

当输入 @close 时,Telnet 命令行显示如下:

@close
Connection closed by foreign host

服务器显示如下:

Session closed

此时,客户端 Telnet 与服务器断开连接。

测试关闭服务器

当输入 @shutdown 时,Telnet 命令行显示如下:

@shutdown
Connection closed by foreign host

服务器显示如下:

Server shutdown

此时服务器会自动关闭。

十九、Go语言死锁、活锁和饥饿概述

本节我们来介绍一下死锁、活锁和饥饿这三个概念。

死锁

死锁是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

死锁发生的条件有如下几种:

1) 互斥条件

线程对资源的访问是排他性的,如果一个线程对占用了某资源,那么其他线程必须处于等待状态,直到该资源被释放。

2) 请求和保持条件

线程 T1 至少已经保持了一个资源 R1 占用,但又提出使用另一个资源 R2 请求,而此时,资源 R2 被其他线程 T2 占用,于是该线程 T1 也必须等待,但又对自己保持的资源 R1 不释放。

3) 不剥夺条件

线程已获得的资源,在未使用完之前,不能被其他线程剥夺,只能在使用完以后由自己释放。

4) 环路等待条件

在死锁发生时,必然存在一个“进程 - 资源环形链”,即:{p0,p1,p2,…pn},进程 p0(或线程)等待 p1 占用的资源,p1 等待 p2 占用的资源,pn 等待 p0 占用的资源。

最直观的理解是,p0 等待 p1 占用的资源,而 p1 而在等待 p0 占用的资源,于是两个进程就相互等待。

死锁解决办法:

  • 如果并发查询多个表,约定访问顺序;
  • 在同一个事务中,尽可能做到一次锁定获取所需要的资源;
  • 对于容易产生死锁的业务场景,尝试升级锁颗粒度,使用表级锁;
  • 采用分布式事务锁或者使用乐观锁。

死锁程序是所有并发进程彼此等待的程序,在这种情况下,如果没有外界的干预,这个程序将永远无法恢复。

为了便于大家理解死锁是什么,我们先来看一个例子(忽略代码中任何不知道的类型,函数,方法或是包,只理解什么是死锁即可),代码如下所示:

package main
import (
    "fmt"
    "runtime"
    "sync"
    "time"
)
type value struct {
    memAccess sync.Mutex
    value     int
}
func main() {
    runtime.GOMAXPROCS(3)
    var wg sync.WaitGroup
    sum := func(v1, v2 \*value) {
        defer wg.Done()
        v1.memAccess.Lock()
        time.Sleep(2 \* time.Second)
        v2.memAccess.Lock()
        fmt.Printf("sum = %d\n", v1.value+v2.value)
        v2.memAccess.Unlock()
        v1.memAccess.Unlock()
    }
    product := func(v1, v2 \*value) {
        defer wg.Done()
        v2.memAccess.Lock()
        time.Sleep(2 \* time.Second)
        v1.memAccess.Lock()
        fmt.Printf("product = %d\n", v1.value\*v2.value)
        v1.memAccess.Unlock()
        v2.memAccess.Unlock()
    }
    var v1, v2 value
    v1.value = 1
    v2.value = 1
    wg.Add(2)
    go sum(&v1, &v2)
    go product(&v1, &v2)
    wg.Wait()
}

运行上面的代码,可能会看到:

fatal error: all goroutines are asleep - deadlock!

为什么呢?如果仔细观察,就可以在此代码中看到时机问题,以下是运行时的图形表示。

一个因时间问题导致死锁的演示
图 :一个因时间问题导致死锁的演示

活锁

活锁是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复同样的操作,而且总会失败。

例如线程 1 可以使用资源,但它很礼貌,让其他线程先使用资源,线程 2 也可以使用资源,但它同样很绅士,也让其他线程先使用资源。就这样你让我,我让你,最后两个线程都无法使用资源。

活锁通常发生在处理事务消息中,如果不能成功处理某个消息,那么消息处理机制将回滚事务,并将它重新放到队列的开头。这样,错误的事务被一直回滚重复执行,这种形式的活锁通常是由过度的错误恢复代码造成的,因为它错误地将不可修复的错误认为是可修复的错误。

当多个相互协作的线程都对彼此进行相应而修改自己的状态,并使得任何一个线程都无法继续执行时,就导致了活锁。这就像两个过于礼貌的人在路上相遇,他们彼此让路,然后在另一条路上相遇,然后他们就一直这样避让下去。

要解决这种活锁问题,需要在重试机制中引入随机性。例如在网络上发送数据包,如果检测到冲突,都要停止并在一段时间后重发,如果都在 1 秒后重发,还是会冲突,所以引入随机性可以解决该类问题。

下面通过示例来演示一下活锁:

package main
import (
    "bytes"
    "fmt"
    "runtime"
    "sync"
    "sync/atomic"
    "time"
)
func main() {
    runtime.GOMAXPROCS(3)
    cv := sync.NewCond(&sync.Mutex{})
    go func() {
        for range time.Tick(1 \* time.Second) { // 通过tick控制两个人的步调
            cv.Broadcast()
        }
    }()
    takeStep := func() {
        cv.L.Lock()
        cv.Wait()
        cv.L.Unlock()
    }
    tryDir := func(dirName string, dir \*int32, out \*bytes.Buffer) bool {
        fmt.Fprintf(out, " %+v", dirName)
        atomic.AddInt32(dir, 1)
        takeStep()                      //走上一步
        if atomic.LoadInt32(dir) == 1 { //走成功就返回
            fmt.Fprint(out, ". Success!")
            return true
        }
        takeStep() // 没走成功,再走回来
        atomic.AddInt32(dir, -1)
        return false
    }
    var left, right int32
    tryLeft := func(out \*bytes.Buffer) bool {
        return tryDir("向左走", &left, out)
    }
    tryRight := func(out \*bytes.Buffer) bool {
        return tryDir("向右走", &right, out)
    }
    walk := func(walking \*sync.WaitGroup, name string) {
        var out bytes.Buffer
        defer walking.Done()
        defer func() { fmt.Println(out.String()) }()
        fmt.Fprintf(&out, "%v is trying to scoot:", name)
        for i := 0; i < 5; i++ {
            if tryLeft(&out) || tryRight(&out) {
                return
            }
        }
        fmt.Fprintf(&out, "\n%v is tried!", name)
    }
    var trail sync.WaitGroup
    trail.Add(2)
    go walk(&trail, "男人") // 男人在路上走
    go walk(&trail, "女人") // 女人在路上走
    trail.Wait()
}

输出结果如下:

go run main.go
女人 is trying to scoot: 向左走 向右走 向左走 向右走 向左走 向右走 向左走 向右走 向左走 向右走
女人 is tried!
男人 is trying to scoot: 向左走 向右走 向左走 向右走 向左走 向右走 向左走 向右走 向左走 向右走
男人 is tried!

这个例子演示了使用活锁的一个十分常见的原因,两个或两个以上的并发进程试图在没有协调的情况下防止死锁。这就好比,如果走廊里的人都同意,只有一个人会移动,那就不会有活锁;一个人会站着不动,另一个人会移到另一边,他们就会继续移动。

活锁和死锁的区别在于,处于活锁的实体是在不断的改变状态,所谓的“活”,而处于死锁的实体表现为等待,活锁有可能自行解开,死锁则不能。

饥饿

饥饿是指一个可运行的进程尽管能继续执行,但被调度器无限期地忽视,而不能被调度执行的情况。

与死锁不同的是,饥饿锁在一段时间内,优先级低的线程最终还是会执行的,比如高优先级的线程执行完之后释放了资源。

活锁与饥饿是无关的,因为在活锁中,所有并发进程都是相同的,并且没有完成工作。更广泛地说,饥饿通常意味着有一个或多个贪婪的并发进程,它们不公平地阻止一个或多个并发进程,以尽可能有效地完成工作,或者阻止全部并发进程。

下面的示例程序中包含了一个贪婪的 goroutine 和一个平和的 goroutine:

package main
import (
    "fmt"
    "runtime"
    "sync"
    "time"
)
func main() {
    runtime.GOMAXPROCS(3)
    var wg sync.WaitGroup
    const runtime = 1 \* time.Second
    var sharedLock sync.Mutex
    greedyWorker := func() {
        defer wg.Done()
        var count int
        for begin := time.Now(); time.Since(begin) <= runtime; {
            sharedLock.Lock()
            time.Sleep(3 \* time.Nanosecond)
            sharedLock.Unlock()
            count++
        }
        fmt.Printf("Greedy worker was able to execute %v work loops\n", count)
    }
    politeWorker := func() {
        defer wg.Done()
        var count int
        for begin := time.Now(); time.Since(begin) <= runtime; {
            sharedLock.Lock()
            time.Sleep(1 \* time.Nanosecond)
            sharedLock.Unlock()
            sharedLock.Lock()
            time.Sleep(1 \* time.Nanosecond)
            sharedLock.Unlock()
            sharedLock.Lock()
            time.Sleep(1 \* time.Nanosecond)
            sharedLock.Unlock()
            count++
        }
        fmt.Printf("Polite worker was able to execute %v work loops\n", count)
    }
    wg.Add(2)
    go greedyWorker()
    go politeWorker()
    wg.Wait()
}

输出如下:

Greedy worker was able to execute 276 work loops
Polite worker was able to execute 92 work loops

贪婪的 worker 会贪婪地抢占共享锁,以完成整个工作循环,而平和的 worker 则试图只在需要时锁定。两种 worker 都做同样多的模拟工作(sleeping 时间为 3ns),可以看到,在同样的时间里,贪婪的 worker 工作量几乎是平和的 worker 工作量的两倍!

假设两种 worker 都有同样大小的临界区,而不是认为贪婪的 worker 的算法更有效(或调用 Lock 和 Unlock 的时候,它们也不是缓慢的),我们得出这样的结论,贪婪的 worker 不必要地扩大其持有共享锁上的临界区,井阻止(通过饥饿)平和的 worker 的 goroutine 高效工作。

总结

不适用锁肯定会出问题。如果用了,虽然解了前面的问题,但是又出现了更多的新问题。

  • 死锁:是因为错误的使用了锁,导致异常;
  • 活锁:是饥饿的一种特殊情况,逻辑上感觉对,程序也一直在正常的跑,但就是效率低,逻辑上进行不下去;
  • 饥饿:与锁使用的粒度有关,通过计数取样,可以判断进程的工作效率。

只要有共享资源的访问,必定要使其逻辑上进行顺序化和原子化,确保访问一致,这绕不开锁这个概念。

二十、Go语言聊天服务器

本节将带领大家结合咱们前面所学的知识开发一个聊天的示例程序,它可以在几个用户之间相互广播文本消息。

服务端程序

服务端程序中包含 4 个 goroutine,分别是一个主 goroutine 和广播(broadcaster)goroutine,每一个连接里面又包含一个连接处理(handleConn)goroutine 和一个客户写入(clientwriter)goroutine。

广播器(broadcaster)是用于如何使用 select 的一个规范说明,因为它需要对三种不同的消息进行响应。

主 goroutine 的工作是监听端口,接受连接客户端的网络连接,对每一个连接,它将创建一个新的 handleConn goroutine。

完整的示例代码如下所示:

package main
import (
    "bufio"
    "fmt"
    "log"
    "net"
)
func main() {
    listener, err := net.Listen("tcp", "localhost:8000")
    if err != nil {
        log.Fatal(err)
    }
    go broadcaster()
    for {
        conn, err := listener.Accept()
        if err != nil {
            log.Print(err)
            continue
        }
        go handleConn(conn)
    }
}
type client chan<- string // 对外发送消息的通道
var (
    entering = make(chan client)
    leaving  = make(chan client)
    messages = make(chan string) // 所有连接的客户端
)
func broadcaster() {
    clients := make(map[client]bool)
    for {
        select {
        case msg := <-messages:
            // 把所有接收到的消息广播给所有客户端
            // 发送消息通道
            for cli := range clients {
                cli <- msg
            }
        case cli := <-entering:
            clients[cli] = true
        case cli := <-leaving:
            delete(clients, cli)
            close(cli)
        }
    }
}
func handleConn(conn net.Conn) {
    ch := make(chan string) // 对外发送客户消息的通道
    go clientWriter(conn, ch)
    who := conn.RemoteAddr().String()
    ch <- "欢迎 " + who
    messages <- who + " 上线"
    entering <- ch
    input := bufio.NewScanner(conn)
    for input.Scan() {
        messages <- who + ": " + input.Text()
    }
    // 注意:忽略 input.Err() 中可能的错误
    leaving <- ch
    messages <- who + " 下线"
    conn.Close()
}
func clientWriter(conn net.Conn, ch <-chan string) {
    for msg := range ch {
        fmt.Fprintln(conn, msg) // 注意:忽略网络层面的错误
    }
}

代码中 main 函数里面写的代码非常简单,其实服务器要做的事情总结一下无非就是获得 listener 对象,然后不停的获取链接上来的 conn 对象,最后把这些对象丢给处理链接函数去进行处理。

在使用 handleConn 方法处理 conn 对象的时候,对不同的链接都启一个 goroutine 去并发处理每个 conn 这样则无需等待。

由于要给所有在线的用户发送消息,而不同用户的 conn 对象都在不同的 goroutine 里面,但是Go语言中有 channel 来处理各不同 goroutine 之间的消息传递,所以在这里我们选择使用 channel 在各不同的 goroutine 中传递广播消息。

下面来介绍一下 broadcaster 广播器,它使用局部变量 clients 来记录当前连接的客户集合,每个客户唯一被记录的信息是其对外发送消息通道的 ID,下面是细节:

type client chan<- string // 对外发送消息的通道
var (
    entering = make(chan client)
    leaving  = make(chan client)
    messages = make(chan string) // 所有连接的客户端
)
func broadcaster() {
    clients := make(map[client]bool)
    for {
        select {
        case msg := <-messages:
            // 把所有接收到的消息广播给所有客户端
            // 发送消息通道
            for cli := range clients {
                cli <- msg
            }
        case cli := <-entering:
            clients[cli] = true
        case cli := <-leaving:
            delete(clients, cli)
            close(cli)
        }
    }
}

在 main 函数里面使用 goroutine 开启了一个 broadcaster 函数来负责广播所有用户发送的消息。

这里使用一个字典来保存用户 clients,字典的 key 是各连接申明的单向并发队列。

使用一个 select 开启一个多路复用:

  • 每当有广播消息从 messages 发送进来,都会循环 cliens 对里面的每个 channel 发消息。
  • 每当有消息从 entering 里面发送过来,就生成一个新的 key - value,相当于给 clients 里面增加一个新的 client。
  • 每当有消息从 leaving 里面发送过来,就删掉这个 key - value 对,并关闭对应的 channel。

下面再来看一下每个客户自己的 goroutine。

handleConn 函数创建一个对外发送消息的新通道,然后通过 entering 通道通知广播者新客户到来,接着它读取客户发来的每一行文本,通过全局接收消息通道将每一行发送给广播者,发送时在每条消息前面加上发送者 ID 作为前缀。一旦从客户端读取完毕消息,handleConn 通过 leaving 通道通知客户离开,然后关闭连接。

func handleConn(conn net.Conn) {
    ch := make(chan string) // 对外发送客户消息的通道
    go clientWriter(conn, ch)
    who := conn.RemoteAddr().String()
    ch <- "欢迎 " + who
    messages <- who + " 上线"
    entering <- ch
    input := bufio.NewScanner(conn)
    for input.Scan() {
        messages <- who + ": " + input.Text()
    }
    // 注意:忽略 input.Err() 中可能的错误
    leaving <- ch
    messages <- who + " 下线"
    conn.Close()
}
func clientWriter(conn net.Conn, ch <-chan string) {
    for msg := range ch {
        fmt.Fprintln(conn, msg) // 注意:忽略网络层面的错误
    }
}

handleConn 函数会为每个过来处理的 conn 都创建一个新的 channel,开启一个新的 goroutine 去把发送给这个 channel 的消息写进 conn。

handleConn 函数的执行过程可以简单总结为如下几个步骤:

  • 获取连接过来的 ip 地址和端口号;
  • 把欢迎信息写进 channel 返回给客户端;
  • 生成一条广播消息写进 messages 里;
  • 把这个 channel 加入到客户端集合,也就是 entering <- ch;
  • 监听客户端往 conn 里写的数据,每扫描到一条就将这条消息发送到广播 channel 中;
  • 如果关闭了客户端,那么把队列离开写入 leaving 交给广播函数去删除这个客户端并关闭这个客户端;
  • 广播通知其他客户端该客户端已关闭;
  • 最后关闭这个客户端的连接 Conn.Close()。
客户端程序

前面对服务端做了简单的介绍,下面介绍客户端,这里将其命名为“netcat.go”,完整代码如下所示:

// netcat 是一个简单的TCP服务器读/写客户端
package main
import (
    "io"
    "log"
    "net"


![img](https://img-blog.csdnimg.cn/img_convert/132002f623530036b476f522df9b8064.png)
![img](https://img-blog.csdnimg.cn/img_convert/fd4da7ab78a1fb9549d922983346248e.png)
![img](https://img-blog.csdnimg.cn/img_convert/13800c88a480e434e8c458e033470629.png)

**既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!**

**由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新**

**[如果你需要这些资料,可以戳这里获取](https://bbs.csdn.net/topics/618658159)**

    }
}

在 main 函数里面使用 goroutine 开启了一个 broadcaster 函数来负责广播所有用户发送的消息。

这里使用一个字典来保存用户 clients,字典的 key 是各连接申明的单向并发队列。

使用一个 select 开启一个多路复用:

  • 每当有广播消息从 messages 发送进来,都会循环 cliens 对里面的每个 channel 发消息。
  • 每当有消息从 entering 里面发送过来,就生成一个新的 key - value,相当于给 clients 里面增加一个新的 client。
  • 每当有消息从 leaving 里面发送过来,就删掉这个 key - value 对,并关闭对应的 channel。

下面再来看一下每个客户自己的 goroutine。

handleConn 函数创建一个对外发送消息的新通道,然后通过 entering 通道通知广播者新客户到来,接着它读取客户发来的每一行文本,通过全局接收消息通道将每一行发送给广播者,发送时在每条消息前面加上发送者 ID 作为前缀。一旦从客户端读取完毕消息,handleConn 通过 leaving 通道通知客户离开,然后关闭连接。

func handleConn(conn net.Conn) {
    ch := make(chan string) // 对外发送客户消息的通道
    go clientWriter(conn, ch)
    who := conn.RemoteAddr().String()
    ch <- "欢迎 " + who
    messages <- who + " 上线"
    entering <- ch
    input := bufio.NewScanner(conn)
    for input.Scan() {
        messages <- who + ": " + input.Text()
    }
    // 注意:忽略 input.Err() 中可能的错误
    leaving <- ch
    messages <- who + " 下线"
    conn.Close()
}
func clientWriter(conn net.Conn, ch <-chan string) {
    for msg := range ch {
        fmt.Fprintln(conn, msg) // 注意:忽略网络层面的错误
    }
}

handleConn 函数会为每个过来处理的 conn 都创建一个新的 channel,开启一个新的 goroutine 去把发送给这个 channel 的消息写进 conn。

handleConn 函数的执行过程可以简单总结为如下几个步骤:

  • 获取连接过来的 ip 地址和端口号;
  • 把欢迎信息写进 channel 返回给客户端;
  • 生成一条广播消息写进 messages 里;
  • 把这个 channel 加入到客户端集合,也就是 entering <- ch;
  • 监听客户端往 conn 里写的数据,每扫描到一条就将这条消息发送到广播 channel 中;
  • 如果关闭了客户端,那么把队列离开写入 leaving 交给广播函数去删除这个客户端并关闭这个客户端;
  • 广播通知其他客户端该客户端已关闭;
  • 最后关闭这个客户端的连接 Conn.Close()。
客户端程序

前面对服务端做了简单的介绍,下面介绍客户端,这里将其命名为“netcat.go”,完整代码如下所示:

// netcat 是一个简单的TCP服务器读/写客户端
package main
import (
    "io"
    "log"
    "net"


[外链图片转存中...(img-yoTsiN7G-1715415147978)]
[外链图片转存中...(img-hjfAjPSb-1715415147978)]
[外链图片转存中...(img-RoyNW3Af-1715415147978)]

**既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!**

**由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新**

**[如果你需要这些资料,可以戳这里获取](https://bbs.csdn.net/topics/618658159)**

  • 13
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值