golang Goroutine协程和Channel管道

同步/并发/并行概念

在计算机科学和软件开发中,同步、并发和并行是三个重要的概念,它们描述了程序执行的不同方式

同步 (Synchronization)

  • 定义:同步指的是一种操作或任务在进行时,调用者需要等待该操作或任务完成才能继续执行 在同步操作中,任务的执行是顺序的,即一个任务完成后,才能开始执行下一个任务
  • 特点
    • 任务之间存在依赖关系,一个任务必须等待另一个任务完成之后才能继续
    • 通常涉及到锁、信号量、条件变量等机制来确保正确的执行顺序
    • 同步可以发生在单线程或多线程环境中

并发 (Concurrency)

  • 定义:并发是指系统能够处理多个任务几乎同时发生的能力 在并发执行中,单个处理器通过任务间切换的方式给人“同时处理多个任务”的错觉 实际上,这些任务在不同时间段内共享一个处理器,交替执行

  • 特点

    • 多个任务共享相同的资源,如 CPU 时间片、内存等
    • 并发可以提高程序的响应性和效率,因为它允许多个任务在等待 I/O 操作或其他长时间运行的操作时继续执行其他任务
    • 并发可以在单处理器或多处理器的系统上实现
    • 并发通常涉及到调度算法来决定哪个任务何时执行

并行 (Parallelism)

  • 定义:并行是指多个处理器或多核处理器同时处理多个任务的情况 并行执行要求物理上同时发生,即在同一时刻有多个任务在执行
  • 特点
    • 并行通常需要多个处理器核心或多个计算机来实现
    • 并行可以显著加快程序的执行速度,因为它允许多个任务同时执行
    • 并行通常涉及到更复杂的同步机制,以确保数据的一致性和完整性

同步/并发/并行关系

  • 同步与并发

    • 在并发程序中,同步机制被用来协调多个任务的执行顺序
    • 同步机制确保即使在并发环境下,程序也能按照预期的方式执行
  • 并发与并行

    • 并发并不总是意味着并行 在单核处理器上,多个任务可以并发执行,但它们实际上是轮流运行的
    • 在多核处理器上,真正的并行执行成为可能,这时并发和并行的概念更加接近

示例

  • 同步:如果程序需要按顺序处理文件 A、B 和 C,那么必须先处理完 A 文件才能开始处理 B 文件,依次类推
  • 并发:如果程序可以在等待文件 A 的 I/O 操作完成的同时开始处理文件 B,那么这就是并发执行
  • 并行:如果程序能够在多核处理器上同时处理文件 A 和文件 B,那么就是并行执行

Go 语言中的应用

  • 同步:使用互斥锁(sync.Mutex)、读写锁(sync.RWMutex)、等待组(sync.WaitGroup)等,来确保 goroutines 之间的同步
  • 并发:通过 goroutines 和 channels 来实现并发执行,使得多个任务看起来像是同时执行
  • 并行:Go 运行时调度器会尝试利用多核处理器的优势,通过调度 goroutines 在多个 CPU 核心上并行执行

goroutine协程

定义

Goroutine 是 Go 语言中实现并发的核心概念 它是一个函数或方法,可以独立于其他函数或方法运行 Go 语言的运行时会为每个 Goroutine 分配很小的栈内存(初始栈大小通常为几千字节),并根据需要自动进行栈的扩展

Goroutine 的特点

  • 有独立的栈空间
  • 共享程序堆空间
  • 调度由用户控制
  • 协程是轻量级的线程

创建 Goroutine

创建 goroutine 非常简单,只需要在函数调用前加上 go 关键字即可

func main() {
    go myFunction()  // 创建一个 goroutine
}

func myFunction() {
    // 这里的代码将在新的 goroutine 中运行
    fmt.Println("Hello from a goroutine!")
}

使用goroutine

启动单个goroutine

func hello() {
    fmt.Println("hello函数已执行")
}
func main() {
    go hello()
    fmt.Println("主程序输出")
    time.Sleep(time.Second)
}
  • 当main()函数返回的时候该goroutine就结束了,所有在main()函数中启动的goroutine会一同结束
  • 可以使用time.Sleepsync.WaitGroup来实现goroutine的同步

启动多个goroutine

var wg sync.WaitGroup

func hello(i int) {
    defer wg.Done() // goroutine结束就登记-1
    fmt.Println("Hello Goroutine!", i)
}
func main() {

    for i := 0; i < 10; i++ {
        wg.Add(1) // 启动一个goroutine就登记+1
        go hello(i)
    }
    wg.Wait() // 等待所有登记的goroutine都结束
}
  • 多次执行上面的代码,会发现每次打印的数字的顺序都不一致。这是因为10个goroutine是并发执行的,而goroutine的调度是随机的。

Goroutine 的调度

  • GPM是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统。区别于操作系统调度OS线程。

    • G很好理解,就是个goroutine的,里面除了存放本goroutine信息外 还有与所在P的绑定等信息。
    • P管理着一组goroutine队列,P里面会存储当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界),P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务。
    • M(machine)是Go运行时(runtime)对操作系统内核线程的虚拟, M与内核线程一般是一一映射的关系, 一个groutine最终是要放到M上执行的

    img

  • P与M一般也是一一对应的。他们关系是: P管理着一组G挂载在M上运行。当一个G长久阻塞在一个M上时,runtime会新建一个M,阻塞G所在的P会把其他的G 挂载在新建的M上。当旧的G阻塞完成或者认为其已经死掉时 回收旧的M。

  • P的个数是通过runtime.GOMAXPROCS设定(最大256),Go1.5版本之后默认为物理线程数。 在并发量大的时候会增加一些P和M,但不会太多,切换太频繁的话得不偿失。

  • 单从线程调度讲,Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的,goroutine则是由Go运行时(runtime)自己的调度器调度的,这个调度器使用一个称为m:n调度的技术(复用/调度m个goroutine到n个OS线程)。 其一大特点是goroutine的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身goroutine的超轻量,以上种种保证了go调度方面的性能。

使用defer +recover捕获错误

1.在 Go 语言中,panic 是一个内建的错误处理机制,用于在发现严重错误时立即停止当前 goroutine 的执行,并向上传播错误。当一个 goroutine 调用 panic 时,它会停止当前的函数调用链,并返回其调用者,直到调用链中有一个 recover 调用为止。如果没有 recover 调用,panic 会导致整个程序崩溃。
2.recover 是一个函数,它可以捕获并恢复一个 goroutine 中的 panic。当你在 goroutine 中使用 recover 时,它可以捕获到在 panic 之前的所有 panic 调用。一旦捕获到一个 panicrecover 会恢复 goroutine 的执行,并返回一个非空的值,通常是 nil
以下是使用 recover 解决协程中 panic 的一些注意事项:

  • 避免全局变量:当使用 recover 时,它会改变 goroutine 的执行上下文,这可能会影响到全局变量。因此,避免在全局作用域或全局变量中使用 recover

  • 避免嵌套使用recover 通常在一个 goroutine 的栈帧中调用。如果在一个函数中嵌套调用 recover,可能会导致错误的行为。

  • 使用 goroutine 包裹:如果你需要在 goroutine 中捕获 panic,最好将 recover 放在一个单独的函数中,并使用 go 关键字启动一个新的 goroutine 来调用这个函数。这样可以避免嵌套使用 recover 带来的问题。

  • 捕获 panic 后的处理:当 recover 捕获到一个 panic 时,你需要决定如何处理这个 panic。你可以选择忽略它、记录日志、恢复程序的执行或执行其他恢复操作。

  • 不要过度使用recover 应该只在必要时使用。如果一个 goroutine 的执行过程中出现错误,应该考虑使用 defer 和错误处理来优雅地处理错误,而不是立即调用 panic

示例:

package main
import (
	"fmt"
	"time"
)
func main() {
	go func() {
		defer func() {
			if err := recover(); err != nil {
				fmt.Println("Recovered from panic:", err)
			}
		}()
		// 模拟可能引发 panic 的代码
		n := 10
		if n == 0 {
			panic("Division by zero")
		}
		result := 10 / n
		fmt.Println("Result:", result)
	}()
	time.Sleep(1 * time.Second)
}

在这个例子中,我们启动了一个新的 goroutine,并在其中模拟了可能引发 panic 的代码。在 defer 块中,我们使用 recover 来捕获并处理 panic。这样,即使 panic 发生,程序也不会崩溃,而是会在控制台打印出捕获到的 panic 信息。

Channel管道

channel

  • 单纯地将函数并发执行是没有意义的。函数与函数间需要交换数据才能体现并发执行函数的意义。

  • 虽然可以使用共享内存进行数据交换,但是共享内存在不同的goroutine中容易发生竞态问题。为了保证数据交换的正确性,必须使用互斥量对内存进行加锁,这种做法势必造成性能问题。

  • Go语言的并发模型是CSP(Communicating Sequential Processes),提倡通过通信共享内存而不是通过共享内存而实现通信。

  • 如果说goroutine是Go程序并发的执行体,channel就是它们之间的连接。channel是可以让一个goroutine发送特定值到另一个goroutine的通信机制。

  • Go 语言中的通道(channel)是一种特殊的类型。通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。每一个通道都是一个具体类型的导管,也就是声明channel的时候需要为其指定元素类型。

channel类型

channel是一种类型,一种引用类型。声明通道类型的格式如下:

    var 变量 chan 元素类型

例子:

    var ch1 chan int   // 声明一个传递整型的通道
    var ch2 chan bool  // 声明一个传递布尔型的通道
    var ch3 chan []int // 声明一个传递int切片的通道

创建channel

通道是引用类型,通道类型的空值是nil。

var ch chan int
fmt.Println(ch) // <nil>

声明的通道后需要使用make函数初始化之后才能使用。

创建channel的格式如下:

    make(chan 元素类型, [缓冲大小])

channel的缓冲大小是可选的。

例子:

ch4 := make(chan int)
ch5 := make(chan bool)
ch6 := make(chan []int)

channel操作

通道有发送(send)、接收(receive)和关闭(close)三种操作。

发送和接收都使用<-符号。

现在我们先使用以下语句定义一个通道:

ch := make(chan int)

发送

将一个值发送到通道中。

ch <- 10 // 把10发送到ch中

接收

从一个通道中接收值。

x := <- ch // 从ch中接收值并赋值给变量x
<-ch       // 从ch中接收值,没有接受者

关闭

我们通过调用内置的close函数来关闭通道。

    close(ch)

关于关闭通道需要注意的事情是,只有在通知接收方goroutine所有的数据都发送完毕的时候才需要关闭通道。通道是可以被垃圾回收机制回收的,它和关闭文件是不一样的,在结束操作之后关闭文件是必须要做的,但关闭通道不是必须的。

关闭后的通道有以下特点:

  • 对一个关闭的通道再发送值就会导致panic。
  • 对一个关闭的通道进行接收会一直获取值直到通道为空。
  • 对一个关闭的并且没有值的通道执行接收操作会得到对应类型的零值。
  • 关闭一个已经关闭的通道会导致panic。

不带缓存的Channel

不带缓存的 Channel(也称为无缓冲 Channel)是一种特殊类型的通道,它在发送和接收操作之间提供了一种同步机制。

特点

  1. 同步通信:在不带缓存的 Channel 上,发送操作会阻塞,直到另一个 goroutine 在同一 Channel 上执行接收操作。同样,接收操作也会阻塞,直到有另一个 goroutine 在同一 Channel 上执行发送操作。
  2. 一次只能处理一个值:由于没有缓冲区,所以一次只能通过 Channel 传递一个值。这意味着每次发送和接收操作都是一对一的。
  3. 零值:如果 Channel 是用 make 函数创建的,它的零值是 nil。发送或接收一个 nil Channel 将永远阻塞。

创建

不带缓存的 Channel 可以使用 make 函数创建,如下所示:

ch := make(chan int) // 创建一个无缓冲的 int 类型的 Channel

使用示例

package main
import "fmt"
func main() {
    ch := make(chan int) // 创建无缓冲的 Channel
    go func() {
        fmt.Println("Sending 42")
        ch <- 42 // 发送操作
    }()
    num := <-ch // 接收操作
    fmt.Println("Received", num)
}

在这个例子中,主 goroutine 会阻塞在接收操作上,直到另一个 goroutine 发送一个值。一旦发送操作完成,接收操作就会继续执行,并且打印出接收到的值。

注意事项

  • 死锁:如果不小心,无缓冲 Channel 可能会导致死锁。例如,如果一个 goroutine 在发送操作上阻塞,但没有其他 goroutine 来接收,或者相反,都会导致死锁。
  • 资源泄露:确保所有发送操作都有对应的接收操作,以避免 goroutine 永远阻塞。
  • 关闭 Channel:当不再需要 Channel 时,应该关闭它。从已关闭的 Channel 接收数据是安全的,但如果尝试向已关闭的 Channel 发送数据,则会引发 panic。

串联的Channels(Pipelin)

一个Channel的输出作为下一个Channel的输入,当一个channel被关闭后,再向该channel发送数据将导致panic异常。当一个被关闭的channel中已经发送的数据都被成功接收后,后续的接收操作将不再阻塞,它们会立即返回一个零值。

package main

import (
	"fmt"
)

// 第一阶段:生成数字并发送到下游
func generate(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		for _, n := range nums {
			out <- n
		}
		close(out)
	}()
	return out
}

// 第二阶段:从上游接收数字,乘以 2,然后发送到下游
func multiply(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		for n := range in {
			out <- n * 2
		}
		close(out)
	}()
	return out
}

// 第三阶段:从上游接收数字并打印
func print(in <-chan int) {
	for n := range in {
		fmt.Println(n)
	}
}

func main() {
	// 创建 Pipeline
	nums := generate(2, 3, 4, 5)
	double := multiply(nums)
	print(double)

	// 等待打印完成(在这个例子中不是必须的,因为 main 函数会等待所有启动的 goroutine 完成)
}
  • generate 函数创建一个 Channel 并发送一系列数字。
  • multiply 函数从上游 Channel 接收数字,将它们乘以 2,并将结果发送到下游 Channel。
  • print 函数从上游 Channel 接收数字并打印它们。

串联 Channels 的 Pipeline 模式在处理大规模数据流、复杂的数据转换和并发计算时非常有用。通过将问题分解为多个简单的阶段,可以更容易地理解和维护代码。

channel 接收的方式可以改进

利用第二个返回值判断 channel 是否关闭 

go func() {
    for {
        x, ok := <-naturals
        if !ok {
            break 
        }
        squares <- x * x
    }
    close(squares)
}()

l利用for range 形式
for x := range squares {
       fmt.Println(x)
}

试图重复关闭一个channel将导致panic异常,试图关闭一个nil值的channel也将导致panic异常。关闭一个channels还会触发一个广播机制

带缓存的Channels

带缓存的 Channels 是 Go 语言中一种具有特定缓冲容量的通道类型。这种通道允许发送者在没有对应接收者的情况下发送多个值,只要缓冲区未满。以下是带缓存 Channels 的一些特点和用法:

特点

  1. 缓冲区大小:带缓存的 Channel 在创建时指定缓冲区的大小,例如 ch := make(chan int, 3) 创建了一个缓冲区大小为 3 的 int 类型 Channel。
  2. 异步通信:与无缓冲 Channel 不同,带缓存 Channel 的发送操作在缓冲区未满时不会阻塞。只有当缓冲区满了,发送者才会阻塞,直到有接收者从 Channel 中接收数据。
  3. 缓冲区满/空时的行为:如果 Channel 的缓冲区已满,发送操作会阻塞,直到有接收者从 Channel 中取出一个值。相反,如果缓冲区为空,接收操作会阻塞,直到有发送者向 Channel 发送数据。

创建

带缓存的 Channel 可以使用 make 函数创建,并提供缓冲区的大小作为第二个参数:

ch := make(chan int, bufferSize) // bufferSize 是缓冲区的大小

使用示例

以下是一个使用带缓冲 Channel 的简单示例:

package main
import "fmt"
func main() {
    ch := make(chan int, 2) // 创建缓冲区大小为 2 的 Channel
    go func() {
        for i := 0; i < 5; i++ {
            ch <- i // 发送操作,缓冲区未满时不会阻塞
            fmt.Println("Sent:", i)
        }
        close(ch) // 发送完毕后关闭 Channel
    }()
    for num := range ch { // 接收操作,缓冲区为空时会阻塞
        fmt.Println("Received:", num)
    }
}

在这个例子中,goroutine 试图向 Channel 发送 5 个数字,但由于缓冲区大小为 2,所以前两次发送操作不会阻塞。第三次发送操作将在缓冲区满时阻塞,直到主 goroutine 从 Channel 中接收一个值。

注意事项

  • 缓冲区大小:选择合适的缓冲区大小对于程序的性能和正确性至关重要。太大或太小的缓冲区都可能导致问题。
  • 关闭 Channel:发送者完成发送操作后应该关闭 Channel。接收者可以通过 range 循环来自动处理关闭的 Channel。
  • 数据竞争和同步:尽管带缓存 Channel 提供了一定程度的异步性,但在某些情况下仍然需要同步机制来避免数据竞争。
  • 死锁:确保程序中的所有发送操作都有对应的接收操作,以避免死锁。
    带缓存的 Channels 在需要一定程度的异步处理和流量控制时非常有用,但正确使用它们需要仔细考虑并发和同步的细节。

单向Channel

在 Go 语言中,单向管道(或称为单向通道)是一种特殊的通道类型,它只能用于发送或接收数据,但不能同时进行。单向通道通过指定通道的方向来增加类型安全性,防止意外的发送或接收操作

单向发送通道(chan<-

单向发送通道只能用于发送数据,不能从中接收数据。其类型表示为 chan<- T,其中 T 是通道传输的数据类型

单向接收通道(<-chan

单向接收通道只能用于接收数据,不能向其发送数据。其类型表示为 <-chan T,同样 T 是通道传输的数据类型。

使用示例

以下是一个使用单向通道的示例:

package main
import "fmt"
// sender 函数只接受一个单向发送通道作为参数
func sender(ch chan<- int) {
	for i := 0; i < 5; i++ {
		ch <- i // 发送数据到通道
	}
	close(ch) // 发送完毕后关闭通道
}
// receiver 函数只接受一个单向接收通道作为参数
func receiver(ch <-chan int) {
	for num := range ch { // 接收通道中的数据
		fmt.Println("Received:", num)
	}
}
func main() {
	ch := make(chan int) // 创建一个双向通道
	go sender(ch) // 启动发送者goroutine
	receiver(ch) // 在主goroutine中接收数据
}

在这个例子中:

  • sender 函数有一个 chan<- int 类型的参数,这意味着它只能向通道发送整数,而不能从通道接收
  • receiver 函数有一个 <-chan int 类型的参数,这意味着它只能从通道接收整数,而不能向通道发送

注意事项

  • 类型安全性:使用单向通道可以增强代码的类型安全性,因为它限制了通道的使用方式
  • 接口适配:在某些情况下,你可能需要将双向通道转换为单向通道,以便与需要单向通道的接口或函数进行适配
  • 传递规则:你可以将一个双向通道传递给一个接受单向发送通道的函数,因为这是类型兼容的。反之亦然,你可以将一个双向通道传递给一个接受单向接收通道的函数

将双向通道转换为单向通道的示例:

ch := make(chan int) // 双向通道
// 将双向通道转换为单向发送通道
sendOnly := (chan<- int)(ch)
// 将双向通道转换为单向接收通道
receiveOnly := (<-chan int)(ch)

基于select的多路复用

在 Go 语言中,select 语句是一种用于在多个通道(channel)上进行多路复用的机制。它允许一个 goroutine 同时等待多个通道操作,然后根据哪个通道准备好来执行相应的代码块。

关键点

  1. 多路复用select 可以监听多个通道上的发送和接收操作
  2. 随机执行:如果有多个通道准备好,select 会随机选择一个执行
  3. 阻塞:如果没有通道准备好,select 语句会阻塞,直到至少有一个通道可以进行操作
  4. 默认情况:可以有一个 default 分支,在没有通道准备好时执行

使用示例

以下是一个使用 select 的基本示例:

package main
import (
	"fmt"
	"time"
)
func main() {
	ch1 := make(chan string)
	ch2 := make(chan string)
	go func() {
		time.Sleep(1 * time.Second)
		ch1 <- "one"
	}()
	go func() {
		time.Sleep(2 * time.Second)
		ch2 <- "two"
	}()
	for i := 0; i < 2; i++ {
		select {
		case msg1 := <-ch1:
			fmt.Println("Received from ch1:", msg1)
		case msg2 := <-ch2:
			fmt.Println("Received from ch2:", msg2)
		}
	}
}

在这个例子中,我们有两个通道 ch1ch2,以及两个分别向这两个通道发送数据的 goroutine。select 语句用于等待这两个通道上的消息。由于 ch1ch2 之前准备好,所以它可能会先被选中。

注意事项

  • 防止饥饿:由于 select 的随机性,长时间运行的 select 可能会导致某些通道的饥饿(即某些通道的值始终无法被接收)。可以通过适当的逻辑来避免这种情况

  • 默认分支:在 select 中使用 default 分支可以避免无限期的阻塞,这在需要定期执行某些操作时很有用

  • 关闭通道:在 select 中使用 range 循环可以优雅地处理关闭的通道

包含 default 分支的示例

package main
import (
	"fmt"
	"time"
)
func main() {
	ch := make(chan string)
	go func() {
		for i := 0; i < 3; i++ {
			ch <- fmt.Sprintf("message %d", i)
			time.Sleep(time.Second)
		}
		close(ch)
	}()
	for {
		select {
		case msg := <-ch:
			fmt.Println("Received:", msg)
		case <-time.After(2 * time.Second):
			fmt.Println("Timeout: no message received")
			return
		}
	}
}

在这个例子中,我们使用 select 来等待从 ch 接收消息或等待超时。如果 2 秒内没有从 ch 接收到消息,则会打印超时信息并退出循环
select 是 Go 语言并发编程中一个非常有用的特性,它使得可以灵活地处理多个并发通道上的事件

协程是轻量级的线程,可以在同一个程序中并发地执行多个任务。通过使用协程,我们可以更有效地利用计算资源并实现并发编程。而管道是用于在协程传递数据的通信机制。在Go语言中,我们可以使用管道来实现协程的同步和通信。 在Go语言中,我们可以通过以下步骤来使用协程管道: 1. 使用关键字"go"来创建一个协程,让其并发执行一个函数或方法。 2. 使用"make"函数来创建一个管道,并指定其元素类型和容量。管道可以是有缓冲的(指定了容量)或者无缓冲的(未指定容量)。 3. 在协程中,使用"<-"操作符将数据发送到管道中,或者从管道中接收数据。 4. 如果管道是无缓冲的,发送操作和接收操作会导致发送方和接收方都会阻塞,直到对应的操作完成。这种情况下,协程的通信是同步的。 5. 如果管道是有缓冲的,发送操作只有在管道已满时才会阻塞,接收操作只有在管道为空时才会阻塞。这种情况下,协程的通信是异步的。 下面是一个示例代码来演示协程管道的使用: ```go package main import ( "fmt" ) func worker(id int, jobs <-chan int, results chan<- int) { for j := range jobs { fmt.Println("worker", id, "processing job", j) results <- j * 2 } } func main() { jobs := make(chan int, 5) results := make(chan int, 5) // 创建3个协程来并发执行任务 for w := 1; w <= 3; w++ { go worker(w, jobs, results) } // 发送5个任务到管道中 for j := 1; j <= 5; j++ { jobs <- j } close(jobs) // 从结果管道中接收并打印结果 for r := 1; r <= 5; r++ { fmt.Println(<-results) } } ``` 在这个示例中,我们创建了一个有缓冲的"jobs"管道和一个有缓冲的"results"管道。然后,我们创建了3个协程来并发执行任务。每个协程从"jobs"管道中接收任务,处理任务后将结果发送到"results"管道中。最后,主函数从"results"管道中接收并打印结果。 希望这个示例能够帮助你理解如何在Go语言中使用协程管道
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值