Golang并发编程——并发详解

关于标*章节

标*的章节表示会在专栏下,单独写博客去讲解

Slogan

在很多环境中,为了实现对共享变量的正确访问,并发编程是很困难(很繁琐)的。Golang鼓励使用一种不同的方式:通过使用通道channel来传递共享值。在这种方式中,任何时间都只有一个goroutine可以访问该值,也就不会发生数据竞争。golang为了鼓励这种方式,提出了口号:

Do not communicate by sharing memory; instead, share memory by communicating.
通信以共享内存,而非共享内存以通信

官网原文:
Concurrent programming is a large topic and there is space only for some Go-specific highlights here.

Concurrent programming in many environments is made difficult by the subtleties required to implement correct access to shared variables. Go encourages a different approach in which shared values are passed around on channels and, in fact, never actively shared by separate threads of execution. Only one goroutine has access to the value at any given time. Data races cannot occur, by design. To encourage this way of thinking we have reduced it to a slogan:

Do not communicate by sharing memory; instead, share memory by communicating.

This approach can be taken too far. Reference counts may be best done by putting a mutex around an integer variable, for instance. But as a high-level approach, using channels to control access makes it easier to write clear, correct programs.

One way to think about this model is to consider a typical single-threaded program running on one CPU. It has no need for synchronization primitives. Now run another such instance; it too needs no synchronization. Now let those two communicate; if the communication is the synchronizer, there’s still no need for other synchronization. Unix pipelines, for example, fit this model perfectly. Although Go’s approach to concurrency originates in Hoare’s Communicating Sequential Processes (CSP), it can also be seen as a type-safe generalization of Unix pipes.

Goroutine

Goroutine与系统线程

Goroutine是 Go 语言特有的并发体,是一种轻量级的线程,由 go 关键字启动。在真实的 Go 语言的实现中,goroutine 和系统线程也不是等价的。尽管两者的区别实际上只是一个量的区别,但正是这个量变引发了 Go 语言并发编程质的飞跃。

首先,每个系统级线程都会有一个固定大小的栈(一般默认可能是 2MB),这个栈主要用来保存函数递归调用时参数和局部变量。固定了栈的大小导致了两个问题:一是对于很多只需要很小的栈空间的线程来说是一个巨大的浪费,二是对于少数需要巨大栈空间的线程来说又面临栈溢出的风险。针对这两个问题的解决方案是:要么降低固定的栈大小,提升空间的利用率;要么增大栈的大小以允许更深的函数递归调用,但这两者是没法同时兼得的。相反,一个 Goroutine 会以一个很小的栈启动(可能是 2KB 或 4KB),当遇到深度递归导致当前栈空间不足时,Goroutine 会根据需要动态地伸缩栈的大小(主流实现中栈的最大值可达到1GB)。因为启动的代价很小,所以我们可以轻易地启动成千上万个 Goroutine。

Go的运行时还包含了其自己的调度器,这个调度器使用了一些技术手段,可以在 n 个操作系统线程上多工调度 m 个 Goroutine。Go 调度器的工作和内核的调度是相似的,但是这个调度器只关注单独的 Go 程序中的 Goroutine。Goroutine 采用的是半抢占式的协作调度,只有在当前 Goroutine 发生阻塞时才会导致调度;同时发生在用户态,调度器会根据具体函数只保存必要的寄存器,切换的代价要比系统线程低得多。运行时有一个 runtime.GOMAXPROCS 变量,用于控制当前运行正常非阻塞 Goroutine 的系统线程数目。

在 Go 语言中启动一个 Goroutine 不仅和调用函数一样简单,而且 Goroutine 之间调度代价也很低,这些因素极大地促进了并发编程的流行和发展。

Goroutine介绍

在java/c++中我们要实现并发编程的时候,我们通常需要自己维护一个线程池,并且需要自己去包装一个又一个的任务,同时需要自己去调度线程执行任务并维护上下文切换,这一切通常会耗费程序员大量的心智。那么能不能有一种机制,程序员只需要定义很多个任务,让系统去帮助我们把这些任务分配到CPU上实现并发执行呢?

Go语言中的goroutine就是这样一种机制,goroutine的概念类似于线程,但 goroutine是由Go的运行时(runtime)调度和管理的。Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU。Go语言之所以被称为现代化的编程语言,就是因为它在语言层面已经内置了调度和上下文切换的机制。

在Go语言编程中你不需要去自己写进程、线程、协程,你的技能包里只有一个技能–goroutine,当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个goroutine去执行这个函数就可以了,就是这么简单粗暴。

Goroutine调度

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

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

Goroutine的使用

启动goroutine的方式非常简单,只需要在调用的函数(普通函数和匿名函数)前面加上一个go关键字。

一个goroutine必定对应一个函数,可以创建多个goroutine去执行相同的函数。

func printInt(value int) {
	fmt.Printf("%d\n", value)
}

func main() {
	for i := 0; i < 5; i++ {
		// 开启多协程去执行
		go printInt(i)
	}
	fmt.Println("main goroutine done.")
	// main 协程等待其他协程执行完
	time.Sleep(time.Second)
}
// 输出
main goroutine done.
0
1
3
4
2

示例中为什么main协程需要等待其他协程执行完成?

在程序启动时,Go程序就会为main()函数创建一个默认的goroutine。当main()函数返回的时候该goroutine就结束了,所有在main()函数中启动的goroutine会一同结束。所以我们要想办法让main函数等一等hello函数,最简单粗暴的方式就是time.Sleep了。

runtime包*

runtime包是一个提供与Go语言运行时系统交互的包。它包含了一些操作底层运行时系统的函数和变量,可以用于控制并发、垃圾回收、堆栈管理等方面。
【我打算后面写一篇专门介绍runtime包的博客,所以此处就不去细说了,只介绍几个常用的方法】

GOMAXPROCS 方法

func GOMAXPROCS(n int) int
GOMAXPROCS 设置可以同时执行的 CPU 的最大数量并返回之前的设置。它默认为runtime.NumCPU 的值。如果 n < 1,则不会更改当前设置。当调度程序改进时,这个调用就会消失。

NumGoroutine 方法

func NumGoroutine() int
返回当前存在的 goroutine 数量。

NumCPU 方法

func NumCPU() int
NumCPU 返回当前进程可用的逻辑CPU 的数量。通过在进程启动时查询操作系统来检查可用 CPU 集。进程启动后操作系统 CPU 分配的更改不会反映出来。

Gosched方法

func Gosched()
让出CPU时间片,重新等待安排任务,允许其他 goroutine 运行。它不会暂停当前的 goroutine,因此会自动恢复执行。

Goexit方法

func Goexit()
终止调用它的 goroutine。没有其他 goroutine 受到影响。 Goexit 在终止 goroutine 之前运行所有延迟调用。因为 Goexit 不是恐慌,所以这些延迟函数中的任何恢复调用都将返回 nil。

从主 Goroutine 调用 Goexit 会终止该 Goroutine,而不会返回 func main 。由于 func main 还没有返回,程序继续执行其他 goroutine。如果所有其他 goroutine 都退出,程序就会崩溃。

示例:

func printInt(value int) {
	time.Sleep(time.Second)
	fmt.Printf("---%d\n", value)
}
func main() {

	for i := 0; i < 5; i++ {
		// 开启多协程去执行
		go printInt(i)
	}
	fmt.Println("main goroutine done.")
	runtime.Goexit()
}
// 输出:
main goroutine done.
---1
---2
---0
---4
---3
fatal error: no goroutines (main called runtime.Goexit) - deadlock!

Channel

关于Channel

很多业务场景中,我们都需要在并发执行的函数与函数间进行交换数据。虽然可以使用共享内存进行数据交换,但是共享内存在不同的goroutine中容易发生竞态问题。为了保证数据交换的正确性,必须使用互斥量对内存进行加锁,这种做法势必造成性能问题。

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

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

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

Channel的声明创建

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

var 通道名称 chan 元素类型 // 格式

var myChannel chan int // 示例

channel通道是引用类型,通道类型的零值是nil。所以声明的通道后需要使用make函数初始化之后才能使用。初始化通道的格式如下:

var 通道名称 = make(chan 元素类型, 缓冲区大小) // 有缓冲区的通道初始化格式

var 通道名称 = make(chan 元素类型) // 无缓冲区的通道初始化格式

var myChannel = make(chan int,3) // 示例 初始化一个缓冲大小为3的int类型通道
var myChannel = make(chan int) // 示例 初始化一个没有缓冲区int类型通道

可以使用内置的len函数获取通道内元素的数量,使用cap函数获取通道的容量

Channel缓冲区

什么是Channel缓冲区

Channel中的缓冲区是Channel内部存储元素的空间,用于临时存储发送的数据。

当创建一个Channel时,可以选择是否为其指定一个缓冲区大小。如果创建的Channel具有缓冲区,那么它可以在发送数据时暂时存储一定数量的元素,而不需要阻塞发送方。这使得发送方能够继续执行,而不必等待接收方接收数据。

缓冲区的大小决定了Channel可以缓存多少个元素。例如,创建一个具有缓冲区大小为3的Channel,可以存储3个元素。当发送元素到Channel时,只有当缓冲区已满时,发送方才会被阻塞。同样,当接收方从Channel接收元素时,只有当缓冲区为空时,接收方才会被阻塞。

使用缓冲区的Channel可以有效地处理发送和接收操作之间存在的时间差,提高并发性能。

无缓冲区通道

无缓冲通道上的发送操作会阻塞,直到另一个goroutine在该通道上执行接收操作,这时值才能发送成功,两个goroutine将继续执行。相反,如果接收操作先执行,接收方的goroutine将阻塞,直到另一个goroutine在该通道上发送一个值。

使用无缓冲通道进行通信将导致发送和接收的goroutine同步化。因此,无缓冲通道也被称为同步通道。

image-20231217165833373

示例:

func main() {
	myChannel := make(chan int)
	myChannel <- 1
}
// 输出
fatal error: all goroutines are asleep - deadlock!   

示例中之所以会出现死锁,就是因为向通道中发送值的操作将一直阻塞。

有缓冲区通道

如果创建的Channel具有缓冲区,那么它可以在发送数据时暂时存储一定数量的元素,而不需要阻塞发送方。这使得发送方能够继续执行,而不必等待接收方接收数据。

只有当缓冲区已满时,发送方才会被阻塞。只有当缓冲区为空时,接收方才会被阻塞。

image-20231217170019197

Channel操作

发送

通道的发送操作将一个元素发送到通道,格式如下:

通道名称 <- 变量名称 // 格式

myChannel <- x // 示例 

此操作将x变量发送到通道中

  • 如果通道缓冲区还有空间,就将该元素保存到缓冲区。
  • 如果通道无缓冲区,就阻塞,直到有另一个goroutine从通道中接收值。
  • 如果通道缓冲区已满,就阻塞,直到另一个goroutine从通道缓存区取走值后,才将元素保存到缓冲区。

接收

通道的接收操作是从通道中取走一个元素,格式如下:


变量名称,是否有值 <- 通道名称 // 带是否有值标志格式

变量名称 <- 通道名称 // // 不带是否有值标志

x , ok <- myChannel // 示例 
x <- myChannel // 示例 

<- myChannel // 忽略取到的值

此操作将从通道中取走一个元素并赋值给x变量

  • 如果通道缓冲区中有元素,就从缓冲区中取走元素。接收操作的第一个返回值为取到的元素,第二个返回值为true。
  • 如果通道无缓冲区或缓冲区没有元素,就阻塞,直到有另一个goroutine向通道中发送值。
  • 当通道已经关闭时,接收操作的第一个返回值为元素类型的零值,第二个返回值为false。

关闭

通道的关闭操作将关闭指定的通道,格式如下:

close(通道名称) // 格式

close(myChannel) // 示例 

此操作关闭通道

  • 只有在通知接收方goroutine所有的数据都发送完毕的时候才需要关闭通道。
  • 对一个关闭的通道再发送值就会导致panic。
  • 对一个关闭的通道进行接收会一直获取值直到通道为空。对一个关闭的并且没有值的通道执行接收操作会得到对应类型的零值。
  • 关闭一个已经关闭的通道会导致panic。

通道是可以被垃圾回收机制回收的,它和关闭文件是不一样的,在结束操作之后关闭文件是必须要做的,但关闭通道不是必须的。

异常情况总结

在这里插入图片描述

for-range 优雅接收

当通过通道发送有限的数据时,我们可以通过close函数关闭通道来告知从该通道接收值的goroutine停止等待。

当通道被关闭时,往该通道发送值会引发panic,从该通道里接收的值一直都是类型零值。

​ 那如何判断一个通道是否被关闭了呢?按前面的方法可以这样写:


func receive(c chan int) {
	for {
		v, ok := <-c // 当通道关闭时,ok值为false
		if ok {
			fmt.Printf("receive %d\n", v)
		} else {
			fmt.Printf("receive end\n")
			return // 当ok为false,表示通道关闭,直接返回
		}
	}
}

可以使用for-range来优雅第从通道中循环取值。

func receiveWithRange(c chan int) {
	for i := range c {
		fmt.Printf("receive %d\n", i)
	}
	fmt.Printf("receive end\n")
}

在这种方式中,如果通道缓冲区无值且已经关闭,会直接结束for-range循环。

select 多路复用

select语句的作用和格式

在某些场景下我们需要同时从多个通道接收数据。通道在接收数据时,如果没有数据可以接收将会发生阻塞。你也许会写出如下代码使用遍历的方式来实现:

for{
    // 尝试从ch1接收值
    data, ok := <-ch1
    // 尝试从ch2接收值
    data, ok := <-ch2
    …
}

这种方式虽然可以实现从多个通道接收值的需求,但是运行性能会差很多。为了应对这种场景,Go内置了select关键字,可以同时响应多个通道的操作。select的使用类似于switch语句:

select {
    case 通道操作1// doSomething
    case 通道操作2:
    	// doSomething
    default:
    	// doSomething
}

select语句包含一系列的case分支和一个默认分支(case分支和default分支都可以省略),每个case分支对应一个通道操作(接收/发送)。

select语句的执行流程

在这里插入图片描述

注意事项
  • 与switch语句一样,case子句下的break关键字,是跳出select语句,而非它外层的循环(如果有的话)。
  • 当有多个case分支的通信操作可以执行时,只会随机执行其中一个。这不仅仅是指case分支下的语句,还包括case分支的通信操作。

单向通道

在Go语言中,单向通道(One-way Channel)是指限制通道的发送或接收操作的通道。通过限制通道的发送或接收操作,可以实现更严格的通信模式,提高代码的可靠性和可读性。

在创建通道时,可以使用特殊的语法来指定通道的方向。具体而言,可以使用箭头符号<-来指定通道的发送或接收方向。它们的用法如下:

  • <-chan T:表示只能从通道中接收类型为 T 的值,即只能用于接收操作的通道。
  • chan<- T:表示只能向通道发送类型为 T 的值,即只能用于发送操作的通道。

这种限制使得在编写程序时,可以明确地指定通道的用途,防止在不正确的地方进行发送或接收操作,从而减少错误的发生。

单向通道在并发编程中非常有用,因为它们可以帮助提高代码的清晰度和可靠性。例如,下面的示例展示了如何使用单向通道:

func send(ch chan<- int, value int) {
    ch <- value
}

func receive(ch <-chan int) {
    value := <-ch
    fmt.Println("Received:", value)
}

func main() {
    ch := make(chan int)
    go send(ch, 42)
    receive(ch)
}

在上述示例中,send 函数接受一个发送操作的单向通道 chan<- int,而 receive 函数接受一个接收操作的单向通道 <-chan int。这样,编译器会在编译时检查是否在正确的地方使用了通道的发送或接收操作。

特别注意:

  • 双向通道可以转换为任意类型的单向通道。
  • 任何类型的单向通道都不能转换成双向通道。
  • 单向通道只能转换为相应通道类型的单向类型,而不能逆转。例如,chan<- int 类型的通道不能转换为<-chan int类型的通道。

并发安全与锁

竞态问题

有时候在Go代码中可能会存在多个goroutine同时操作一个资源(临界区),这种情况会发生竞态问题(数据竞态)。

示例:

func main() {
	var sum int

	waitGroup := sync.WaitGroup{}
	add := func() {
		defer waitGroup.Done()
		for i := 0; i < 5000; i++ {
			sum++
		}
	}

	for i := 0; i < 2; i++ {
		waitGroup.Add(1)
		go add()
	}

	waitGroup.Wait()
	fmt.Println(sum)
}

在上面的示例中,开启了两个goroutine分别将sum累加5000次,期望结果应该时10000,然而大多数情况下,程序输出并非10000.

这就是因为两个goroutine在访问和修改sum变量的时候存在数据竞争,导致结果与期望的结果不符。

互斥锁

互斥锁的作用

互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个goroutine可以访问共享资源。

Go语言中使用sync包的Mutex类型来实现互斥锁。

使用互斥锁来修复上面代码的问题:

func main() {
	var sum int

	// 互斥锁
	var mutex sync.Mutex

	waitGroup := sync.WaitGroup{}
	add := func() {
		defer waitGroup.Done()
		for i := 0; i < 5000; i++ {
			mutex.Lock()
			sum++
			mutex.Unlock()
		}
	}

	for i := 0; i < 2; i++ {
		waitGroup.Add(1)
		go add()
	}

	waitGroup.Wait()
	fmt.Println(sum)
}

重点关注:

  • 使用互斥锁能够保证同一时间有且只有一个goroutine进入临界区,其他的goroutine则在等待锁。
  • 当互斥锁释放后,等待的goroutine才可以获取锁进入临界区。
  • 多个goroutine同时等待一个锁时,唤醒的策略是随机的。
    定义互斥锁
    源码:
type Mutex struct {
	state int32
	sema  uint32
}
  • sync.Mutex是一种互斥锁。它的零值是未锁定的互斥锁。首次使用后不得复制互斥体。
  • 由于它的零值就是未锁定的互斥锁,所以声明后就可以直接使用了。
  • Go语言中的互斥锁是不可重入锁,不支持重入。

互斥锁方法

Go语言中的互斥锁提供了以下三个互斥锁的操作方法:

func (m *Mutex) Lock()

Lock方法用于锁住互斥锁(获取互斥锁)。

如果锁已被其他goroutine锁住,则调用的Goroutine 会阻塞,直到互斥锁可用。

func (m *Mutex) TryLock() bool

TryLock方法用于尝试锁住互斥锁(获取互斥锁),并且返回操作是否成功。

如果互斥锁已被其他goroutine锁住,则直接返回false;如果获取锁住互斥锁成功,则返回true。

Note that while correct uses of TryLock do exist, they are rare, and use of TryLock is often a sign of a deeper problem in a particular use of mutexes.

func (m *Mutex) Unlock()

Unlock方法用于解锁互斥锁(释放互斥锁)。

如果互斥锁在 Unlock 时未锁定,则会发生运行时错误。

互斥锁不与特定的 goroutine 关联。允许一个 Goroutine 锁定一个 Mutex,然后安排另一个 Goroutine 解锁它。

读写锁

读写锁的作用

互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当我们并发的去读取一个资源不涉及资源修改的时候是没有必要加锁的,这种场景下使用读写锁是更好的一种选择。

读写锁分为两种:

  • 读锁
    当一个goroutine获取读锁之后,其他的goroutine如果是获取读锁会继续获得锁,如果是获取写锁就会等待。
  • 写锁
    当一个goroutine获取写锁之后,其他的goroutine无论是获取读锁还是写锁都会等待。

定义读写锁

读写锁在Go语言中使用sync包中的RWMutex类型:

type RWMutex struct {
	w           Mutex  // held if there are pending writers
	writerSem   uint32 // semaphore for writers to wait for completing readers
	readerSem   uint32 // semaphore for readers to wait for completing writers
	readerCount int32  // number of pending readers
	readerWait  int32  // number of departing readers
}
  • RWMutex 是读写互斥锁。该锁可以由任意数量的读取器或单个写入器持有。
  • RWMutex 的零值是未锁定的互斥锁。
  • 首次使用后不得复制 RWMutex。
  • 如果一个 goroutine 持有 RWMutex 进行读取,并且另一个 goroutine 可能调用 Lock,则在释放初始读锁之前,任何 goroutine 都不应该期望能够获取读锁。特别是,这禁止递归读锁定。这是为了确保锁最终变得可用;阻塞的 Lock 调用会阻止新的读取者获取锁。

读写锁方法

Go语言中读写锁有如下几个方法:

  • func (rw *RWMutex) RLock()
    RLock 锁定 rw 以进行读取(获取读锁)。
    它不应该用于递归读锁定;阻塞的 Lock 调用会阻止新的读取者获取锁。

  • func (rw *RWMutex) TryRLock() bool
    TryRLock 尝试锁定 rw 进行读取(获取读锁),并报告是否成功。

  • func (rw *RWMutex) RUnlock()
    RUnlock 撤消单个 RLock 调用(释放读锁)。
    它不会影响其他同时阅读的读者。
    如果 rw 在进入 RUnlock 时未锁定以进行读取,将导致一个运行时错误。

  • func (rw *RWMutex) RLocker() Locker
    RLocker 返回一个 Locker 接口,通过调用 rw.RLock 和 rw.RUnlock 实现 Lock 和 Unlock 方法。

  • func (rw *RWMutex) Lock()
    Lock 锁定 rw 以进行写入(获取写锁)。
    如果该锁已被锁定以进行读取或写入,则 Lock 会阻塞,直到该锁可用为止。

  • func (rw *RWMutex) TryLock() bool
    TryLock 尝试锁定 rw 进行写入(获取写锁),并报告是否成功。

  • func (rw *RWMutex) Unlock()
    Unlock 解锁 rw 的写锁(释放写锁)。
    如果读写锁并没有被写锁锁定,将导致一个运行时错误。

与互斥体一样,锁定的 RWMutex 不与特定的 goroutine 关联。一个 goroutine 可以 RLock(Lock)一个 RWMutex,然后安排另一个 goroutine 对其进行 RUnlock(Unlock)。

sync包下的其他并发工具*

atomic原子操作包*

定时器*

GMP 原理与调度*

参考文献

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值