Go并发编程

目录

一、并发介绍

1.1 进程和线程

二、goroutine包

2.1 使用goroutine

2.2 goroutine与线程

可增长的栈

goroutine调度

三、runtime包

3.1 runtime.Gosched()

3.2 runtime.Goexit()

3.3 runtime.GOMAXPROCS

四、Channel

4.1 声明channel

4.2 创建channel

4.3 channel操作

4.4 无缓冲的通道

4.5 有缓冲的通道

close()

4.6 如何优雅的从通道循环取值

4.7 单向通道

通道常见异常总结:

五、Goroutine池

worker pool(goroutine池)

六、定时器

七、select

7.1 select多路复用

八、并发安全和锁

8.1 互斥锁

8.2 读写互斥锁

九、Sync

9.1 sync.WaitGroup

9.2 sync.Once

9.3 加载配置文件示例

9.4 sync.Map

十、原子操作(atomic包)

原子操作


一、并发介绍

1.1 进程和线程

    A. 进程是程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位。
    B. 线程是进程的一个执行实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。
    C.一个进程可以创建和撤销多个线程;同一个进程中的多个线程之间可以并发执行。  

1.2 协程和线程

协程:独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。
线程:一个线程上可以跑多个协程,协程是轻量级的线程。

1.3 goroutine 只是由官方实现的超级”线程池”。

go高并发的根本原因:每个实例 4~5 KB的栈内存占用 和 由于实现机制而大幅减少的创建和销毁开销

1.4 并发不是并行:

并发主要由切换时间片来实现”同时”运行,并行则是直接利用多核实现多线程的运行,go可以设置使用核数,以发挥多核计算机的能力。

goroutine 通过通信来共享内存,而不是共享内存来通信。

二、goroutine包

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

当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个goroutine去执行这个函数就可以了,就是这么简单粗暴。

2.1 使用goroutine

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

启动单个goroutine

func hello() {
    fmt.Println("Hello Goroutine!")
}

func main() {
    go hello() // 启动另外一个goroutine去执行hello函数
    fmt.Println("main goroutine done!")
}  

执行结果只打印了main goroutine done!,并没有打印Hello Goroutine!。为什么呢?

在程序启动时,Go程序就会为main()函数创建一个默认的goroutine。当main()函数返回的时候该goroutine就结束了,所有在main()函数中启动的goroutine会一同结束。

所以我们要想办法让main函数等一等hello函数,最简单粗暴的方式就是time.Sleep了。

func main() {
    go hello() // 启动另外一个goroutine去执行hello函数
    fmt.Println("main goroutine done!")
    time.Sleep(time.Second)
}  

执行上面的代码你会发现,这一次先打印main goroutine done!,然后紧接着打印Hello Goroutine!。首先为什么会先打印main goroutine done!是因为我们在创建新的goroutine的时候需要花费一些时间,而此时main函数所在的goroutine是继续执行的。

启动多个goroutine

这里使用了sync.WaitGroup来实现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,默认值0
        go hello(i)
    }
    wg.Wait()  //阻塞当前协程(这里是main协程),直到计数器数值归零
}

多次执行上面的代码,会发现每次打印的数字的顺序都不一致。这是因为10个goroutine是并发执行的,而goroutine是随机调度的。

主协程退出,子协程也跟着退出了

package main

import (
    "fmt"
    "time"
)

func main() {
    // 合起来写
    go func() {
        i := 0
        for {
            i++
            fmt.Printf("new goroutine: i = %d\n", i)
            time.Sleep(time.Second)
        }
    }()
    i := 0
    for {
        i++
        fmt.Printf("main goroutine: i = %d\n", i)
        time.Sleep(time.Second)
        if i == 3 {
            break
        }
    }
}

输出:

main goroutine : i = 1
new goroutine : i = 1
new goroutine : i = 2
main goroutine : i = 2
main goroutine : i = 3
new goroutine : i = 3
new goroutine : i = 4

2.2 goroutine与线程

可增长的栈

OS线程(操作系统线程)一般都有固定的栈内存(通常为2MB),一个goroutine的栈在其生命周期开始时只有很小的栈(典型情况下2KB),goroutine的栈不是固定的,他可以按需增大和缩小,goroutine的栈大小限制可以达到1GB,虽然极少会用到这个大。所以在Go语言中一次创建十万左右的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调度方面的性能。

三、runtime包

3.1 runtime.Gosched()

让出CPU时间片,重新等待安排任务(大概意思就是本来计划的好好的周末出去烧烤,但是你妈让你去相亲,两种情况第一就是你相亲速度非常快,见面就黄不耽误你继续烧烤,第二种情况就是你相亲速度特别慢,见面就是你侬我侬的,耽误了烧烤,但是还馋就是耽误了烧烤你还得去烧烤)

package main

import (
    "fmt"
    "runtime"
)

func main() {
    go func(s string) {
        for i := 0; i < 2; i++ {
            fmt.Println(s)
        }
    }("world")
    // 主协程
    for i := 0; i < 2; i++ {
        // 切一下,再次分配任务
        runtime.Gosched()
        fmt.Println("hello")
    }
}

3.2 runtime.Goexit()

退出当前协程(一边烧烤一边相亲,突然发现相亲对象太丑影响烧烤,果断让她滚蛋,然后也就没有然后了)

package main

import (
    "fmt"
    "runtime"
)

func main() {
    go func() {
        defer fmt.Println("A.defer")
        func() {
            defer fmt.Println("B.defer")
            // 结束协程
            runtime.Goexit()
            defer fmt.Println("C.defer")
            fmt.Println("B")
        }()
        fmt.Println("A")
    }()
    for {
    }
}

3.3 runtime.GOMAXPROCS

Go运行时的调度器使用GOMAXPROCS参数来确定需要使用多少个OS线程来同时执行Go代码默认值是机器上的CPU核心数。(GOMAXPROCS是 m : n 调度中的 n)。

可以通过runtime.GOMAXPROCS()函数设置当前程序并发时占用的CPU逻辑核心数

Go1.5版本之前,默认使用的是单核心执行。Go1.5版本之后,默认使用全部的CPU逻辑核心数。

将逻辑核心数设为2,此时两个任务并行执行,代码如下。

func a() {
    for i := 1; i < 10; i++ {
        fmt.Println("A:", i)
    }
}

func b() {
    for i := 1; i < 10; i++ {
        fmt.Println("B:", i)
    }
}

func main() {
    runtime.GOMAXPROCS(2)
    go a()
    go b()
    time.Sleep(time.Second)
}  

Go语言中的操作系统线程和goroutine的关系:

  • 1.一个操作系统线程对应用户态多个goroutine。
  • 2.go程序可以同时使用多个操作系统线程。
  • 3.goroutine 和 OS线程 是多对多的关系,即 m : n。

四、Channel

函数与函数间需要交换数据才能体现并发执行函数的意义

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

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

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

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

4.1 声明channel

    var  c1  chan  int   // 声明一个传递整型的通道
    var  c2  chan  bool  // 声明一个传递布尔型的通道
    var  c3  chan  []int // 声明一个传递int切片的通道    

4.2 创建channel

通道是引用类型,通道类型的空值是nil。声明的通道后需要使用make函数初始化之后才能使用。

ch4  :=  make(chan int)

4.3 channel操作

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

ch <- 10   // 把10发送到ch中   
x := <- ch // 从ch中接收值并赋值给变量x
<-ch       // 从ch中接收值,忽略结果   
close(ch)  //关闭ch 

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

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

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

4.4 无缓冲的通道

无缓冲的通道又称为阻塞的通道。简单来说就是无缓冲的通道必须有接收才能发送。如果接收操作先执行,接收方的goroutine将阻塞,直到另一个goroutine在该通道上发送一个值。

同步通道:使用无缓冲通道进行通信将导致发送和接收的goroutine同步化。

func main() {
    ch := make(chan int)
    ch <- 10
    fmt.Println("发送成功")
}  

///启用一个goroutine去接收值解决ch<-10这一行代码死锁的问题//

func recv(c chan int) {
    ret := <-c
    fmt.Println("接收成功", ret)
}
func main() {
    ch := make(chan int)
    go recv(ch) // 启用goroutine从通道接收值
    ch <- 10
    fmt.Println("发送成功")
}  

4.5 有缓冲的通道

只要通道的容量大于零,那么该通道就是有缓冲的通道,通道的容量表示通道中能存放元素的数量。我们可以使用内置的len函数获取通道内元素的数量,使用cap函数获取通道的容量,虽然我们很少会这么做。

func main() {
    ch := make(chan int, 1) // 创建一个容量为1的有缓冲区通道
    ch <- 10
    fmt.Println("发送成功")
}   

close()

可以通过内置的close()函数关闭channel(如果你的管道不往里存值或者取值的时候一定记得关闭管道)

4.6 如何优雅的从通道循环取值

// channel 练习
func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)
    
    // 开启goroutine将0~100的数发送到ch1中
    go func() {
        for i := 0; i < 100; i++ {
            ch1 <- i
        }
        close(ch1)
    }()

    // 开启goroutine从ch1中接收值,并将该值的平方发送到ch2中
    go func() {
        for {
            i, ok := <-ch1 // 通道关闭后再取值ok=false
            if !ok {
                break
            }
            ch2 <- i * i
        }
        close(ch2)
    }()
    // 在主goroutine中从ch2中接收值打印
    for i := range ch2 { // 通道关闭后会退出for range循环
        fmt.Println(i)
    }
}   

我们看到有两种方式在接收值的时候判断通道是否被关闭,我们通常使用的是for range的方式。

4.7 单向通道

有的时候我们会将通道作为参数在多个任务函数间传递,很多时候我们在不同的任务函数中使用通道都会对其进行限制,比如限制通道在函数中只能发送或只能接收

Go语言中提供了单向通道来处理这种情况。例如,我们把上面的例子改造如下:

func counter(out chan<- int) {
    for i := 0; i < 100; i++ {
        out <- i
    }
    close(out)
}

func squarer(out chan<- int, in <-chan int) {
    for i := range in {
        out <- i * i
    }
    close(out)
}
func printer(in <-chan int) {
    for i := range in {
        fmt.Println(i)
    }
}

func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)
    go counter(ch1)
    go squarer(ch2, ch1)
    printer(ch2)
}   

 1. chan<-    int是一个只能发送的通道,可以发送但是不能接收;
  2. <-chan   int是一个只能接收的通道,可以接收但是不能发送。   

在函数传参及任何赋值操作中将双向通道转换为单向通道是可以的,反过来不行。

通道常见异常总结:

注意:关闭已经关闭的channel也会引发panic。

五、Goroutine池

worker pool(goroutine池)

  • 本质上是生产者消费者模型
  • 可以有效控制goroutine数量,防止暴涨
  • 需求:
    • 计算一个数字的各个位数之和,例如数字123,结果为1+2+3=6
    • 随机生成数字进行计算

六、定时器

golang中timer和ticker 都属于time包

timer是一次性定时器,执行完一次就结束了,ticker是周期性定时器,周而复始的执行。二者在数据结构上完全一样,都是一个对外的channel + 对内的runtimeTimer

type Timer struct {
    C <-chan Time
    r runtimeTimer
}
type Ticker struct {
    C <-chan Time // The channel on which the ticks are delivered.
    r runtimeTimer
}

Timer:时间到了,执行只执行1次

Ticker:时间到了,多次执行

七、select

7.1 select多路复用

在某些场景下我们需要同时从多个通道接收数据。通道在接收数据时,如果没有数据可以接收将会发生阻塞。Go内置了select关键字,可以同时响应多个通道的操作

select的使用类似于switch语句,它有一系列case分支和一个默认的分支。每个case会对应一个通道的通信(接收或发送)过程。select会一直等待,直到某个case的通信操作完成时,就会执行case分支对应的语句。

select可以同时监听一个或多个channel,直到其中一个channel ready

package main

import (
   "fmt"
   "time"
)

func test1(ch chan string) {
   time.Sleep(time.Second * 5)
   ch <- "test1"
}
func test2(ch chan string) {
   time.Sleep(time.Second * 2)
   ch <- "test2"
}

func main() {
   // 2个管道
   output1 := make(chan string)
   output2 := make(chan string)
   // 跑2个子协程,写数据
   go test1(output1)
   go test2(output2)
   // 用select监控
   select {
   case s1 := <-output1:
      fmt.Println("s1=", s1)
   case s2 := <-output2:
      fmt.Println("s2=", s2)
   }
}

如果多个channel同时ready,则随机选择一个执行

package main

import (
	"fmt"
	"time"
)

func test1(ch chan string) {
	time.Sleep(time.Second * 3)
	ch <- "test1"
}
func test2(ch chan string) {
	time.Sleep(time.Second * 1)
	ch <- "test2"
}
func test3(ch chan string) {
	time.Sleep(time.Second * 1)
	ch <- "test3"
}

func main() {
	output1 := make(chan string)
	output2 := make(chan string)
	output3 := make(chan string)
	go test1(output1)
	go test2(output2)
	go test3(output3)
	select {
	case <-output1:
		fmt.Println("output1 成功读到数据!")
	case output2 <- "data2":
		fmt.Println("成功向 output2 写入数据!")
	case s3 := <-output3:
		fmt.Printf("成功向output3中写入数据:%s", s3)
	}
}

可以用于判断管道是否存满

package main

import (
	"fmt"
	"time"
)

// 判断管道有没有存满
func main() {
	// 创建管道
	output1 := make(chan string, 10)
	// 子协程写数据
	go write(output1)
	// 取数据
	for s := range output1 {
		fmt.Println("res:", s)
		time.Sleep(time.Second)
	}
}

func write(ch chan string) {
	for {
		select {
		// 写数据
		case ch <- "hello":
			fmt.Println("write hello")
		default:
			fmt.Println("channel full")
		}
		time.Sleep(time.Millisecond * 500)
	}
}

八、并发安全和锁

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

package main

import (
	"fmt"
	"sync"
)

var x int64
var wg sync.WaitGroup

func add() {
	for i := 0; i < 100; i++ {
		x = x + 1
	}
	wg.Done()
}

func main() {
	wg.Add(2)
	go add()
	go add()
	wg.Wait()
	fmt.Println(x)
}

x++是由3条指令构成的运算操作,两个线程在 x 变量上共计需要执行100(次循环)*3(条指令)*2(个线程)= 600条指令,这600条指令在某种排列下会导致最终i的值仅为2。

x++不是原子操作,而是3条指令:

1、取出内存中 x 的值 放到 CPU的寄存器中

2、将CPU寄存器的值+1

3、把CPU寄存器的值写回内存

多核CPU:2~200;

1. 线程A执行第一次i++,取出内存中的i,值为0,存放到寄存器后执行加1,此时CPU1的寄存器中值为1,内存中为0;

2. 线程B执行第一次i++,取出内存中的i,值为0,存放到寄存器后执行加1,此时CPU2的寄存器中值为1,内存中为0;

3. 线程A继续执行完成第99次i++,并把值放回内存,此时CPU1中寄存器的值为99,内存中为99;

4. 线程B继续执行第一次i++,将其值放回内存,此时CPU2中的寄存器值为1,内存中为1;

5. 线程A执行第100次i++,将内存中的值取回CPU1的寄存器,并执行加1,此时CPU1的寄存器中的值为2,内存中为1;

6. 线程B执行完所有操作,并将其放回内存,此时CPU2的寄存器值为100,内存中为100; 

7. 线程A执行100次操作的最后一部分,将CPU1中的寄存器值放回内存,内存中值为2;

单核CPU最小值为100,最大值200

两个线程分别记为线程1和线程2,i++相当于取出i的值,加1,再放回去

第一种极端情况:每次线程一取出i的值后CPU时间切换到线程二,线程二也取出i的值,取到的值和线程一相等,线程二给i加一后放回去,线程一也将i加一后放回去,放回去的值也相等,相当于两个线程都执行一次i++操作,i的值只增加1,这样操作100次i的值为100

第二种极端情况:线程一和线程二间隔操作,即线程一对i++操作完成,把已经加一的数据放回去之后线程二再操作,轮流进行,最后每个线程都对i加了100次,i的值为200

8.1 互斥锁

Go语言中使用sync包的Mutex类型来实现互斥锁。 使用互斥锁来修复上面代码的问题:

package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup
var lock sync.Mutex
var x int64

func add() {
	for i := 0; i < 100000; i++ {
		lock.Lock()
		x = x + 1
		lock.Unlock()
	}
	wg.Done()
}
func main() {
	wg.Add(2)
	go add()
	go add()
	wg.Wait()
	fmt.Println(x)
}

8.2 读写互斥锁

互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,读写锁在Go语言中使用sync包中的RWMutex类型。

读写锁:

goroutine之间读锁不互斥;

如果一个goroutine已经读锁,其他goroutine再获取写锁就会等待;

当一个goroutine获取写锁之后,其他的goroutine无论是获取读锁还是写锁都会等待。

读锁的使用

import (
    "fmt"
    "sync"
    "time"
)

var rwlock sync.RWMutex

//读锁
rwlock.RLock()  //上锁
rwlock.RUnlock()  //解锁

//写锁
rwlock.Lock()  //上锁
rwlock.UnLock()  //解锁

九、Sync

9.1 sync.WaitGroup

Go语言中可以使用sync.WaitGroup来实现并发任务的同步

sync.WaitGroup内部维护着一个计数器,计数器的值可以增加和减少。例如当我们启动了N 个并发任务时,就使用Add()方法将计数器值增加N。每个任务完成时通过调用Done()方法将计数器减1。通过调用Wait()来等待并发任务执行完,当计数器值为0时,表示所有并发任务已经完成。

9.2 sync.Once

在编程的很多场景下我们需要确保某些操作在高并发的场景下只执行一次,例如只加载一次配置文件、只关闭一次通道等。

sync.Once只有一个Do方法,其签名如下:

func (o *Once) Do(f func()) {} 

注意:如果要执行的 函数 f 需要传递参数就需要搭配闭包来使用。

9.3 加载配置文件示例

延迟一个开销很大的初始化操作 到 真正用到它的时候再执行是一个很好的实践。因为预先初始化一个变量(比如在init函数中完成初始化)会增加程序的启动耗时,而且有可能实际执行过程中这个变量没有用上,那么这个初始化操作就不是必须要做的。

sync.Once其实内部包含一个互斥锁和一个布尔值,互斥锁保证布尔值和数据的安全,而布尔值用来记录初始化是否完成。这样设计就能保证初始化操作的时候是并发安全的并且只执行一次初始化操作

9.4 sync.Map

Go语言中内置的map不是并发安全的。像这种场景下就需要为map加锁来保证并发的安全性了,Go语言的sync包中提供了一个开箱即用的并发安全版map–sync.Map。开箱即用表示不用像内置的map一样使用make函数初始化就能直接使用。同时sync.Map内置了诸如Store、Load、LoadOrStore、Delete、Range等操作方法。

十、原子操作(atomic包)

原子操作

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

var x int64
var l sync.Mutex
var wg sync.WaitGroup

// 普通版加函数
func add() {
	// x = x + 1
	x++ // 等价于上面的操作
	wg.Done()
}

// 互斥锁版加函数
func mutexAdd() {
	l.Lock()
	x++
	l.Unlock()
	wg.Done()
}

// 原子操作版加函数
func atomicAdd() {
	atomic.AddInt64(&x, 1)
	wg.Done()
}

func main() {
	start := time.Now()
	for i := 0; i < 1000000; i++ {
		wg.Add(1)
		//go add() // 普通版add函数 不是并发安全的
		//go mutexAdd() // 加锁版add函数 是并发安全的,但是加锁性能开销大
		go atomicAdd() // 原子操作版add函数 是并发安全,性能优于加锁版
	}
	wg.Wait()
	end := time.Now()
	fmt.Println(x)
	fmt.Println(end.Sub(start))
}

atomic包提供了底层的原子级内存操作,对于同步算法的实现很有用。这些函数必须谨慎地保证正确使用。除了某些特殊的底层应用,使用通道或者sync包的函数/类型实现同步更好

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值