Go语言的并发:goroutine和channel

目录

【Go 的并发方案:goroutine】

goroutine 的基本用法

【通道channel】

创建channel:

发送与接收变量:

关闭channel:

【channel的类型】

 无缓冲channel和带缓冲channel 

无缓冲channel

带缓冲channel

 nil channel

单向channel

【多路选择:select语句】

使用select实现超时控制


并发:指的是一个时间段中有几个程序都处于已启动运行到运行完毕之间,但任一个时刻点上只有一个程序在处理机上运行。并行:指的是在同一时刻有两个或两个以上的进程在处理器(需要是多核处理器)上执行。多进程应用是通过系统调用(比如fork)创建多个子进程,共同实现应用的功能。

进程、线程、协程:

  • 进程:是一个"执行中的程序”,描述的就是程序的执行过程,是运行着的程序的代表。进程的三态模型: 运行、就绪、堵塞。
  • 线程:是进程中的一个实体,可以被视为进程中运行着的控制流,是被操作系统独立调度和分派的基本单位,一个进程至少会包含一个线程。如果一个进程只包含了一个线程,那么它里面的所有代码都只会被串行地执行;如果一个进程中包含了多个线程(其他的线程都是由已存在的线程创建出来的),那么其中的代码就可以被并发地执行。
  • 协程:是一种用户态的轻量级线程,协程的调度由用户控制。一个线程可以拥有多个协程,一个进程也可以单独拥有多个协程。协程的创建、销毁、调度、状态变更以及其中的代码和数据都完全需要程序代码去实现和处理。带来的优势就是速度会很块而且容易控制,不需要操作系统去调度。

单进程单线程:一个人在一个桌子上吃饭(单身)
单进程多线程:多个人在同一个桌子上吃饭(一个家庭里面的多个人 )
多进程单线程:多个人每个人在自己的桌子上吃饭 (多个家庭,但每个家庭都是单身)

多进程多线程:一堆人,每个桌子上都有多个人在吃饭(多个家庭,每个家庭都是多个人)

【Go 的并发方案:goroutine】

操作系统本身提供了进程和线程这两种并发执行程序的工具,而 goroutine 代表着并发编程模型中的用户级线程(也就是协程)。Go 语言实现了基于 CSP理论的并发方案

CSP:Communicating Sequential Processes,通信顺序进程,是一种并发编程模型。关于CSP的更多资料请参考 这里

主要包含两个主要组成部分:

  • 一个是 Goroutine,它是 Go 应用并发设计的基本构建与执行单元;
  • 另一个就是 channel,既可以用来实现 Goroutine 间的通信,还可以实现 Goroutine 间的同步。

goroutine 是由 Go 运行时(runtime)负责调度的、轻量的用户级线程,为并发程序设计提供原生支持。相比传统操作系统线程来说,goroutine 的优势主要是:

  • 资源占用小,每个 goroutine 的初始栈大小仅为 2k;
  • 由 Go 运行时调度(而不是操作系统调度),goroutine 上下文切换在用户层完成,开销更小;
  • 在语言层面提供(而不是通过标准库提供),goroutine 由go关键字创建,一退出就会被回收或销毁;
  • 语言内置 channel 作为 goroutine 间通信原语,为并发设计提供了强大支撑。

goroutine 的基本用法

Go语言使用 go关键词 就可以创建多个 goroutine 执行并发任务,而且还提供了 Channel 类型可以很容易的实现 goroutine 之间的数据通信。具体地说,Go 语言通过 go关键字+函数/方法 的方式创建一个 goroutine。创建后,新 goroutine 将拥有独立的代码执行流,并与创建它的 goroutine 一起被 Go 运行时调度,goroutine 的执行函数返回后就会自动退出。如果 main函数的 goroutine 退出了,那么整个 Go 应用程序也就退出了。

func main() {
	go fmt.Println("你好")
	fmt.Println("这里是main goroutine")
	time.Sleep(time.Millisecond * 500) //等待500毫秒
}

再比如下面的例子:

package main

import (
	"fmt"
	"time"
)

func main() {
	var m = []int{1, 2, 3, 4, 5}
	for i, v := range m {
		go func(i, v int) {
			fmt.Print(i, "=>", v, ",") //4=>5,3=>4,2=>3,0=>1,1=>2,(输出顺序不确定)
		}(i, v)
	}
	time.Sleep(time.Second * 1)
	fmt.Println()
}

【通道channel】

和线程一样,一个应用内部启动的所有 goroutine 共享进程空间的资源,如果多个 goroutine 访问同一块内存数据将会存在竞争,需要进行 goroutine 间的同步。

通道类型channel 的值本身就是并发安全的,这也是 Go 语言自带的、唯一一个可以满足并发安全性的类型。goroutine 执行的函数或方法就算有返回值,Go 也会忽略这些返回值。所以如果要获取 goroutine 执行后的返回值,需要通过 goroutine 间的通信来实现:channel。goroutine 可以从 channel 获取输入数据,再将处理后得到的结果数据通过 channel 输出。

创建channel:

在声明一个channel类型变量的时候首先要确定元素类型,这决定了可以通过这个channel传递什么类型的数据。比如 chan int 表示元素类型为int的channel类型,chan string表示元素类型为string的channel类型。由于channel是引用类型,如果只声明了channel 类型的变量但没有初始化,该变量的默认值是nil。为 channel 类型变量赋初值的唯一方法是使用 make 函数。可以使用cap()函数获取channel的容量,使用len()函数获取channel的元素个数。

/* 创建channel并赋值 */
var ch chan int          //声明一个元素为int类型的channel类型变量,默认值为nil
ch1 := make(chan int)    //声明元素类型为int的channel类型变量,属于无缓冲channel
ch2 := make(chan int, 1) //声明元素类型为int的channel类型变量,并赋初值,属于带缓冲channel

fmt.Println(ch, cap(ch), len(ch))    //nil 0 0
fmt.Println(ch1, cap(ch1), len(ch1)) //0xc00001a2a0 0 0
fmt.Println(ch2, cap(ch1), len(ch1)) //0xc000078000 0 0

当容量为0时 可以称为无缓冲channel,也就是不带缓冲的通道;当容量大于0时称为 带缓冲channel,也就是带有缓冲的通道。 

ch2 := make(chan int, 1) 里面的 1 表示channel的容量,就是指channel最多可以缓存多少个元素值,这个参数是int类型的,而且是不能小于0的。 

发送与接收变量:

一个channel相当于一个先进先出(FIFO)的队列,channel中的各个元素值都是严格地按照发送的顺序排列的,先被发送到channel的元素值一定会先被接收。Go 提供了 <- 操作符用于对 channel 类型变量发送与接收。

ch1 := make(chan int, 3)
ch1 <- 2
ch1 <- 1
ch1 <- 3
data1 := <-ch1
data2 := <-ch1
data3 := <-ch1
fmt.Printf("从channel中接收的元素: %v,%v,%v \n", data1, data2, data3) // 2,1,3

【问】对channel的发送和接收操作都有哪些基本的特性?

(1)对于同一个channel,发送操作之间是互斥的,接收操作之间也是互斥的。同一时刻 同一个channel中,Go 语言的运行时系统只会执行其中一个发送操作,直到发送完成之后其他发送操作才可能被执行,接收操作也是如此。即使这些操作是并发执行的也是如此。而且对于channel中的同一个元素值来说,发送操作和接收操作之间也是互斥的。需要注意的是实际发送的元素是原来元素的副本。

(2)发送操作和接收操作中对元素值的处理都是不可分割的,类似于“事务”处理。发送元素的时候要么还没开始复制副本,要么已经复制完成,不会出现复制了一部分的情况;接收元素的时候一定会删除channel中的原值,而不会出现残留原值的情况。

(3)发送操作在完全完成之前会被阻塞,接收操作也是如此。发送操作包括了 “复制元素值”、“放置副本到channel内部” 两个步骤,这两步完成之前发送操作会一直处于阻塞状态。接收操作包含了 “复制channel内的元素值”、“放置副本到接收方”、“删掉原值” 三个步骤。如此阻塞代码就是为了实现操作的互斥和元素值的完整。

关闭channel:

调用 Go 内置的 close 函数可以关闭channel,所有的channel接收者都会在channel 关闭时立刻从阻塞等待中返回。如下是采用不同接收语法形式的语句,在 channel 被关闭后的返回值的情况:

n := <- ch // 当ch被关闭后,n将被赋值为ch元素类型的零值
m, ok := <-ch // ok为bool值,true表示正常接受,false表示通道关闭. 当ch被关闭后,m将被赋值为ch元素类型的零值, ok值为false
for v := range ch { // 当ch被关闭后,for range循环结束
    // ... ...
}

 channel 的一个使用惯例是:由发送端负责关闭 channel,原因:

  • 发送端没有像接受端那样可以安全判断 channel 是否被关闭了的方法。
  • 向一个已经关闭的 channel 执行发送操作,这个操作就会引发 panic。
  • 关闭一个已经关闭了的通道,也会引发 panic。
ch := make(chan int, 5)
close(ch)
ch <- 13 // panic: send on closed channel

下面是一个正确操作channel的例子:

package main

import "fmt"

func main() {
	ch1 := make(chan int, 2)
	// 发送方
	go func() {
		for i := 0; i < 10; i++ {
			fmt.Printf("发送方发送数据: %v \n", i)
			ch1 <- i
		}
		fmt.Println("发送方: 关闭channel...")
		close(ch1)
	}()

	// 接收方
	for {
		elem, ok := <-ch1
		if !ok {
			fmt.Println("*接收方*: 检测到发送方已经关闭了channel")
			break
		}
		fmt.Printf("*接收方接收数据*: %v \n", elem)
	}

	fmt.Println("执行完成")
}

/*
发送方发送数据: 0
发送方发送数据: 1
发送方发送数据: 2
发送方发送数据: 3
*接收方接收数据*: 0
*接收方接收数据*: 1
*接收方接收数据*: 2
*接收方接收数据*: 3
发送方发送数据: 4
发送方发送数据: 5
发送方发送数据: 6
发送方发送数据: 7
*接收方接收数据*: 4
*接收方接收数据*: 5
*接收方接收数据*: 6
*接收方接收数据*: 7
发送方发送数据: 8
发送方发送数据: 9
发送方: 关闭channel...
*接收方接收数据*: 8
*接收方接收数据*: 9
*接收方*: 检测到发送方已经关闭了channel
执行完成
*/

【channel的类型】

 无缓冲channel和带缓冲channel 

无缓冲channel和带缓冲channel 的最大不同之处就在于它的异步性。也就是说,对一个带缓冲 channel,在缓冲区未满的情况下,对它进行发送操作的 Goroutine 不会阻塞挂起;在缓冲区有数据的情况下,对它进行接收操作的 Goroutine 也不会阻塞挂起。下面是我从网上找的无缓存channel和带缓存channel的示意图:

无缓冲channel

无缓冲channel的容量为0。对同一个无缓冲 channel,只有对它进行接收操作的 Goroutine 和对它进行发送操作的 Goroutine 都存在的情况下才可以通信,否则单方面的操作会让对应的 Goroutine 陷入挂起状态。由此可见;无缓冲channel是用同步的方式传递数据,只有收发双方对接上了数据才会被传递。

ch1 := make(chan int)    //声明元素类型为int的channel类型变量,属于无缓冲channel

// 将 13 发送到 无缓冲channel 类型变量ch1中
ch1 <- 13 //fatal error: all goroutines are asleep - deadlock!

fatal error: all goroutines are asleep - deadlock!

意思是 所有 Goroutine 都处于休眠状态,程序处于死锁状态。要想解除这种错误状态,需要将接收操作或者发送操作放到另外一个 Goroutine 中。

ch1 := make(chan int)    //声明元素类型为int的channel类型变量,属于无缓冲channel

// 将 13 发送到 无缓冲channel 类型变量ch1中
// ch1 <- 13 //fatal error: all goroutines are asleep - deadlock!
go func() {
	ch1 <- 13 // 将发送操作放入一个新goroutine中执行
}()
// 从 无缓冲channel 类型变量ch1 中接收数据 并存储到 变量n中
n := <-ch1
fmt.Println(n) // 13

无缓冲 channel 的典型应用:

  • 第一种用法:用作信号传递无缓冲 channel 用作信号传递的时候,有两种情况,分别是 1 对 1 通知信号和 1 对 n 通知信号。
  • 第二种用法:用于替代锁机制无缓冲 channel 具有同步特性,这让它在某些场合可以替代锁,让我们的程序更加清晰,可读性也更好。

带缓冲channel

带缓冲channel是用异步的方式传递数据,带缓冲channel会作为收发双方的中间件,元素值会先从发送方复制到缓冲channel,之后再由缓冲channel复制给接收方。但是当发送操作在执行的时候发现空的通道中正好有等待的接收操作,那么它会直接把元素值复制给接收方。

对一个带缓冲 channel 来说有下面的情况:

  • 在缓冲区未满的情况下,对它进行发送操作的 Goroutine 并不会阻塞挂起;
  • 在缓冲区有数据的情况下,对它进行接收操作的 Goroutine 也不会阻塞挂起。
  • 当缓冲区满了的情况下,对它进行发送操作的 Goroutine 就会阻塞挂起,直到通道中有元素值被接收走;
  • 当缓冲区为空的情况下,对它进行接收操作的 Goroutine 也会阻塞挂起,直到通道中有新的元素值出现。
ch2 := make(chan int, 1) //声明元素类型为int的channel类型变量,并赋初值,属于带缓冲channel
// s := <-ch2 // 由于此时ch2的缓冲区中无数据,因此对其进行接收操作将导致goroutine挂起

ch2 <- 17 // 将 17 发送到 带缓冲channel 类型变量ch2中, OK
// ch2 <- 18 // 由于此时ch2中缓冲区已满,再向ch2发送数据将导致goroutine挂起.引发fatal error: all goroutines are asleep - deadlock!

fmt.Printf("ch2的容量:%v,元素个数:%v \n", cap(ch2), len(ch2)) //ch2的容量:1,元素个数:1
fmt.Println(<-ch2) // 17
fmt.Printf("ch2的容量:%v,元素个数:%v \n", cap(ch2), len(ch2)) //ch2的容量:1,元素个数:0

对于值为nil的channel,不论它的具体类型是什么,对它的发送操作和接收操作都会永久地处于阻塞状态,它们所属的 goroutine 中的任何代码都不再会被执行。

带缓冲 channel 的典型应用:

  • 第一种用法:用作消息队列,channel 的原生特性与消息队列十分相似,包括 Goroutine 安全、有 FIFO(first-in, first out)保证等。但是 Go 支持 channel 的初衷是将它作为 Goroutine 间的通信手段,它并不是专门用于消息队列场景的。 
  • 第二种用法:用作计数信号量(counting semaphore),带缓冲 channel 中的当前数据个数代表的是,当前同时处于活动状态(处理业务)的 Goroutine 的数量,而带缓冲 channel 的容量(capacity),就代表了允许同时处于活动状态的 Goroutine 的最大数量。

更多 Go语言中 无缓冲 channel 和带缓冲 channel 的用法请参考这里。 

 nil channel

如果一个 channel 类型变量的值为 nil,我们称它为 nil channel。nil channel 有一个特性,那就是对 nil channel 的读写都会发生阻塞。

func main() {
  var c chan int
  <-c //阻塞
}

或者:

func main() {
  var c chan int
  c<-1  //阻塞
}

单向channel

上面说的 channel 都是指的双向通道:既可以发送数据也可以接收数据。另外Go语言中还有单向channel,就是 只能发送数据而不能接收数据,或者只能接收数据而不能发送数据
如果把操作符 <-  用在通道的类型字面量中,那么它代表的就不是“发送”或“接收”的动作了,而是表示通道的方向。(什么意思?有点绕?懵圈?)看个例子对比一下:

chA := make(chan int, 1) //双向channel
ch3 := make(chan<- int, 1) // 单向channel,只能发送数据
ch4 := make(<-chan int, 1) // 单向channel,只能接收数据

只能发送数据的channel叫做:只发送 channel 类型,只能接收数据的channel叫做:只接收 channel 类型。如果从一个 只发送 channel 类型 变量中接收数据,或者向一个 只接收 channel 类型 发送数据,都会导致编译错误。

ch3 := make(chan<- int, 1) // 只发送channel类型
ch4 := make(<-chan int, 1) // 只接收channel类型
// 这里打印的是可以分别代表两个通道的指针的16进制表示
fmt.Printf("单向channel的指针: %v, %v \n", ch3, ch4) //单向channel的指针: 0xc00001c4d0, 0xc00001c540

<-ch3                      // invalid operation: cannot receive from send-only channel ch3 (variable of type chan<- int)
ch4 <- 13                  // invalid operation: cannot send to receive-only channel ch4 (variable of type <-chan int)

从表面上看,声明一个只有一端(发送端或者接收端)能用的通道没有任何意义,因为channel就是为了传递数据而存在的。那么单向 channel 有什么用处?

Go 设计单向channel 最主要的用途就是约束其他代码的行为。通常 只发送 channel 类型 和 只接收 channel 类型 会被用作函数的参数类型或返回值,用于限制对 channel 内的操作,或者是明确可对 channel 进行的操作的类型。比如下面的例子中,声明一个接口类型的方法时使用了单向channel类型,在函数声明的结果列表中也使用单向channel,就对传入的参数起到了约束作用。

package main

import (
	"fmt"
	"math/rand"
)

// 声明一个接口类型的方法时使用单向channel类型
type Constraint interface {
	SendInt(ch chan<- int)
}

// 约束此方法传入的参数只能是channel
func SendInt(ch chan<- int) {
	ch <- rand.Intn(1000)
}

// 在函数声明的结果列表中也使用单向channel
// 返回一个<-chan int类型的channel,约束得到该channel的程序只能从channel中接收元素值
func getIntChan() <-chan int {
	num := 5
	ch := make(chan int, num)
	for i := 0; i < num; i++ {
		ch <- i
	}
	close(ch)
	return ch
}

type GetIntChan func() <-chan int

func main() {
	intChan1 := make(chan int, 3)
	SendInt(intChan1)

	intChan2 := getIntChan()
	for elem := range intChan2 {
		fmt.Printf("intChan2中的元素值: %v\n", elem)
	}

	_ = GetIntChan(getIntChan)
}

/*
intChan2中的元素值: 0
intChan2中的元素值: 1
intChan2中的元素值: 2
intChan2中的元素值: 3
intChan2中的元素值: 4
*/

上面代码的细节分析:

  • 这里的 for range 语句会不断地尝试从 intChan2 中取出元素值,即使intChan2被关闭,它也会在取出所有剩余的元素值之后再结束执行。
  • 当intChan2中没有元素值时,它会被阻塞在有for关键字的那一行,直到有新的元素值可取。
  • 假设intChan2的值为nil,那么它会被永远阻塞在有for关键字的那一行。

再比如下面的代码使用单向channel类型实现了一个简单的消息队列的功能:

package main

import (
	"sync"
	"time"
)

/*
生产者,使用 只发送channel类型 作为参数类型
*/
func produce(ch chan<- int) {
	for i := 0; i < 10; i++ {
		ch <- i + 1
		time.Sleep(time.Second)
	}
	close(ch)  //关闭channel
}

/*
消费者,从channel中接收数据
*/
func consume(ch <-chan int) {
	// for range 会阻塞在channel的接收操作上,直到channel中有数据可接收 或 channel被关闭循环,然后才会继续向下执行。
	for n := range ch {
		print(n, " ")
	}
}
func main() {
	ch := make(chan int, 5)
	var wg sync.WaitGroup
	wg.Add(2)
	go func() {
		produce(ch)
		wg.Done()
	}()

	go func() {
		consume(ch)
		wg.Done()
	}()
	wg.Wait()
}

运行后,每隔1秒钟会分别输出 1-10

【多路选择:select语句】

select 语句只能与channel 一起使用,它由若干个分支组成,每次执行select语句的时候只有一个分支中的代码会被运行,类似switch语句,只不过每个case表达式中都只能包含操作channel的表达式。比如下面的例子:

func test1() {
	// 定义数组类型的channel
	intChannels := [3]chan int{
		make(chan int, 1),
		make(chan int, 1),
		make(chan int, 1),
	}
	// 随机选择一个channel 并发送元素
	// index := rand.Intn(3) //使用一个随机数
	index := 2
	fmt.Printf("索引是 %d,", index)
	intChannels[index] <- index

	// 哪一个通道中有可取的元素值,哪个对应的分支就会被执行
	select {
	case <-intChannels[0]:
		fmt.Println("执行分支0")
	case <-intChannels[1]:
		fmt.Println("执行分支1")
	case element := <-intChannels[2]: //可以把channel元素的值赋给一个普通变量
		fmt.Printf("执行分支2,对应的元素是: %d \n", element)
	default:
		fmt.Println("没有分支被执行")
	}

	//输出: 索引是 2,执行分支2,对应的元素是: 2
}

使用 select 语句的注意事项:

  • 如果加入了default分支,那么涉及channel操作的表达式不管是否有阻塞,此时的select语句都不会被阻塞。如果所有case都没有满足条件,那么default分支就会执行;相反的如果没有default分支,当所有case都没有满足条件的情况下,select语句就会阻塞,直到至少有一个case表达式满足条件为止。
  • select语句如果在for循环中,在select语句中使用break语句只能结束当前的select语句的执行,而并不会对外层的for语句产生作用。
func test2() {
	intChan := make(chan int, 1)
	time.AfterFunc(time.Second, func() { // 一秒后关闭channel
		close(intChan)
	})
	select {
	case _, ok := <-intChan:
		if !ok {
			fmt.Println("channel已关闭")
			break
		}
		fmt.Println("已选择当前分支")
	}

	//输出: channel已关闭
}

select语句的分支选择规则:

  • 每一个case表达式至少会包含一个代表 发送操作或者接收操作 的表达式,同时也可能会包含其他的表达式。
  • 在select语句开始执行时,排在最上边的候选分支中最左边的表达式会最先被求值,然后是它右边的表达式。
  • 对于每一个case表达式,如果其中的发送/接收表达式在被求值时,相应的操作正处于阻塞状态,那么这个case表达式所在的分支是不满足选择条件的。
  • 只有select语句中所有case表达式都被求值完毕后,它才会开始选择候选分支。此时只会挑选满足条件的分支执行;如果所有的分支都不满足条件,那么会执行default 分支;如果没有default分支,select语句就会立即进入阻塞状态,直到至少有一个候选分支满足条件为止。
  • 如果select语句发现同时有多个分支满足条件,那么它会用一种伪随机的算法在这些分支中选择一个并执行。
  • 一条select语句中只能够有一个default分支,并且default分支只会在无分支可选时才被执行,这与它的编写位置无关。
  • select语句的每次执行(包括case表达式求值和分支选择)都是独立的,但至于它的执行是否是并发安全的,还要看其中的case表达式以及分支中是否包含并发不安全的代码。

以上定论可以用下面的代码来验证:

package main

import "fmt"

var channels = [3]chan int{
	nil,
	make(chan int),
	nil,
}

var numbers = []int{1, 2, 3}

func getNumber(i int) int {
	fmt.Printf("numbers[%d]\n", i)
	return numbers[i]
}

func getChan(i int) chan int {
	fmt.Printf("channels[%d]\n", i)
	return channels[i]
}

func main() {
	select {
	case getChan(0) <- getNumber(0):
		fmt.Println("执行第0条分支")
	case getChan(1) <- getNumber(1):
		fmt.Println("执行第1条分支")
	case getChan(2) <- getNumber(2):
		fmt.Println("执行第2条分支")
	default:
		fmt.Println("没有分支被执行")
	}
}

/* 输出:
channels[0]
numbers[0]
channels[1]
numbers[1]
channels[2]
numbers[2]
没有分支被执行
*/

使用select实现超时控制

假设某个goroutine运行非常耗时,可以在select的case分支中设定超时时间,如果超过了预设的时间就执行指定的操作。看下面的代码:

package main

import (
	"fmt"
	"time"
)

func service() string {
	time.Sleep(time.Millisecond * 500) //休息500毫秒
	return "处理完成"
}

func AsyncService() chan string {
	retCh := make(chan string, 1)
	go func() {
		ret := service()
		fmt.Println("returned result.")
		retCh <- ret
		fmt.Println("service exited.")
	}()
	return retCh
}

func main() {
	select {
	case ret := <-AsyncService():
		fmt.Println(ret)
	case <-time.After(time.Millisecond * 100): //设定超时时间为100毫秒
		fmt.Println("超时")
	}
}

 源代码:https://gitee.com/rxbook/go-demo-2023/tree/master/basic/go03/goroutine1

  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

浮尘笔记

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值