Go语言学习之并发编程-DateWhale开源学习

目录

并发与并行

并行: 多个cpu处理多个任务,即多线程程序在多个核的cpu上运行
并发: 一个cpu处理多个任务,即多线程程序在一个核的cpu上运行

进程和线程

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

为什么需要并发?

原因有很多,其中比较重要的原因如下:

  • 不阻塞等待其他任务的执行,从而浪费时间,影响系统性能。
  • 并行可以使系统变得简单些,将复杂的大任务切换成许多小任务执行,单独测试。

在开发中,经常会遇到为什么某些进程通常会相互等待呢?为什么有些运行慢,有些快呢?

通常受限来源于进程I/OCPU

  • 进程I/O限制
    如:等待网络或磁盘访问

  • CPU限制
    如:大量计算

Go并发

协程和线程

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

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

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

协程Goroutines

golang的特点就是语言层面支持并发,并且实现并发非常简单,只需在需要并发的函数前面添加关键字go

每个go程序至少都有一个Goroutine:主Goroutine(在运行进程时自动创建)。以及程序中其他Goroutine 例如:下面程序创建了mainGoroutine及匿名的Goroutine

func main() {
	go func() {
		fmt.Println("hello goroutines !")
	}()
}

sync 包提供了互斥锁这类的基本的同步原语.除 OnceWaitGroup之外的类型大多用于底层库的例程。更高级的同步操作通过信道与通信进行.

WaitGroup

假设主线程要等待其余的goroutine都运行完毕,不得不在末尾添加time.Sleep(),但是这样会引发两个问题:

  • 等待多长时间?
  • 时间太长,影响性能?
    gosync库中的WaitGroup可以帮助我们完成此项工作,WaitGroup用于等待一组例程的结束。主例程在创建每个子例程的时候先调用Add增加等待计数,每个子例程在结束时调用Done减少例程计数。之后,主例程通过Wait方法开始等待,直到计数器归零才继续执行。
  • type WaitGroup
type WaitGroup struct {
        // contains filtered or unexported fields
}
  • func (*WaitGroup) Add
	func (wg *WaitGroup) Add(delta int)

计数器增加delta,如果计数器为零,则释放等待时阻塞的所有goroutine

  • func (*WaitGroup) Done
	func (wg *WaitGroup) Done()

计数器减少1

  • func (*WaitGroup) Wait
	func (*WaitGroup) Wait 

等待直到计数器归零。如果计数器小于 0,则该操作会引发panic

使用示例:等待多个goroutine完成,可以使用一个等待组。

func worker(id int, wg *sync.WaitGroup) {

	defer wg.Done()

	fmt.Printf("Worker %d starting\n", id)

	time.Sleep(time.Second)
	fmt.Printf("Worker %d done\n", id)
}

func main() {

	var wg sync.WaitGroup

	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go worker(i, &wg)
	}

	wg.Wait()
	fmt.Println("Done")
}

这里首先把wg计数设置为1, 每个for循环运行完毕都把计数器减一,主函数中使用Wait() 一直阻塞,直到wg1——也就是for循环都运行完毕。

使用注意点:

  • 计数器不能为负值,否则引发panic
  • WaitGroup对象不是引用类型

Once

Once的作用是多次调用但只执行一次,Once只有一个方法,Once.Do(),向Do传入一个函数,这个函数在第一次执行 Once.Do()的时候会被调用,以后再执行Once.Do()将没有任何动作,即使传入了其它的函数,也不会被执行,如果要执行其它函数,需要重新创建一个Once对象。即sync.Once可以控制函数只能被调用一次,不能多次重复调用。

  • type Once
type Once struct {
        // contains filtered or unexported fields
}
  • func (*Once) Do
	func (o *Once) Do(f func())

使用示例:

package main

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

func main() {

    o := &sync.Once{}
    go myfun(o)
    go myfun(o)
    time.Sleep(time.Second * 2)
}

func myfun(o *sync.Once) {

    fmt.Println("Begin function")

    o.Do(func() {
        fmt.Println("Working...")
    })

    fmt.Println("Function end")
}

互斥锁Mutex

互斥锁用来保证在任一时刻,只能有一个例程访问某对象。Mutex的初始值为解锁状态。Mutex通常作为其它结构体的匿名字段使用,使该结构体具有LockUnlock方法。

  • type Locker
type Locker interface {
        Lock()
        Unlock()
}

Locker接口包装了基本的LockUnLock方法,用于加锁和解锁。

  • type Mutex
type Mutex struct {
        // contains filtered or unexported fields
}
  • func (*Mutex) Lock
	func (m *Mutex) Lock()

Lock用于锁住m,如果m已经被加锁,则Lock将被阻塞,直到m被解锁。

  • func (*Mutex) Unlock
	func (m *Mutex) Unlock()

Unlock用于解锁m,如果m未加锁,则该操作会引发panic

使用示例:

// SafeCounter 的并发使用是安全的。
type SafeCounter struct {
	v   map[string]int
	mux sync.Mutex
}

// Inc 增加给定 key 的计数器的值。
func (c *SafeCounter) Inc(key string) {
  c.mux.Lock()
  defer c.mux.Unlock()
	// Lock 之后同一时刻只有一个 goroutine 能访问 c.v
  c.v[key]++
}

// Value 返回给定 key 的计数器的当前值。
func (c *SafeCounter) Value(key string) int {
	c.mux.Lock()
	// Lock 之后同一时刻只有一个 goroutine 能访问 c.v
	defer c.mux.Unlock()
	return c.v[key]
}

func main() {
	c := SafeCounter{v: make(map[string]int)}
	for i := 0; i < 1000; i++ {
		go c.Inc("somekey")
	}

	time.Sleep(time.Second)
	fmt.Println(c.Value("somekey"))
}

在这个例子中,使用了sync.MutexLockUnlock方法。

RWMutex

sync.Mutex读操作与写操作都会被阻塞。其实读操作的时候我们是不需要进行阻塞的,因此sync中还有另一个锁:读写锁RWMutex,这是一个单写多读模型,可以让多个例程同时读取某对象。RWMutex可以安全的在多个例程中并行使用。

sync.RWMutex分为:读、写锁。在读锁占用下,会阻止写,但不会阻止读,多个goroutine可以同时获取读锁,调用RLock()函数即可,RUnlock()函数释放。写锁会阻止任何goroutine进来,整个锁被当前goroutine,此时等价于Mutex,写锁调用Lock启用,通过UnLock()释放。

  • type RWMutex
type RWMutex struct {
        // contains filtered or unexported fields
}
  • func (*RWMutex) Lock
	func (rw *RWMutex) Lock()

Lockrw设置为写锁定状态,禁止其他例程读取或写入

  • func (*RWMutex) RLock
	func (rw *RWMutex) RLock()

RLockrw设置为读锁定状态,禁止其他例程写入,但可以读取

  • func (*RWMutex) RLocker
	func (rw *RWMutex) RLocker() Locker

RLocker返回一个Locker接口,该接口通过调用rw.RLockrw.RUnlock来实现LockUnlock方法

  • func (*RWMutex) RUnlock
	func (rw *RWMutex) RUnlock()

RUnlock撤消单个RLock调用;它不会影响其他读锁调用。如果在读入RUnlock时未锁定rw以进行读取,则该操作会引发panic

  • func (*RWMutex) Unlock
	func (rw *RWMutex) Unlock()

Unlock解除rw的写锁定状态,如果 rw未被写锁定,则该操作会引发panic

使用示例:对上述例子进行改写,读的时候用读锁,写的时候用写锁。

// SafeCounter 的并发使用是安全的。
type SafeCounter struct {
	v     map[string]int
	rwmux sync.RWMutex
}

// Inc 增加给定 key 的计数器的值。
func (c *SafeCounter) Inc(key string) {
	// 写操作使用写锁
	c.rwmux.Lock()
	defer c.rwmux.Unlock()
	// Lock 之后同一时刻只有一个 goroutine 能访问 c.v
	c.v[key]++
}

// Value 返回给定 key 的计数器的当前值。
func (c *SafeCounter) Value(key string) int {
  // 读的时候加读锁
	c.rwmux.RLock()
	// Lock 之后同一时刻只有一个 goroutine 能访问 c.v
	defer c.rwmux.RUnlock()
	return c.v[key]
}

func main() {
	c := SafeCounter{v: make(map[string]int)}
	for i := 0; i < 1000; i++ {
		go c.Inc("somekey")
	}

	time.Sleep(time.Second)

	for i := 0; i < 10; i++ {
		fmt.Println(c.Value("somekey"))
	}
}

条件变量Cond

Cond用于在并发环境下routine的等待和通知

  • type Cond
type Cond struct {

        // L is held while observing or changing the condition
        L Locker
        // contains filtered or unexported fields
}

在“检查条件”或“更改条件”时 L应该锁定

  • func NewCond
	func NewCond(l Locker) *Cond

创建一个条件等待

  • func (*Cond) Broadcast
	func NewCond(l Locker) *Cond

Broadcast唤醒所有等待的 Wait,建议在“更改条件”时锁定 c.L,更改完毕再解锁

  • func (*Cond) Signal
	func (c *Cond) Signal()

Signal唤醒一个等待的Wait,建议在“更改条件”时锁定c.L,更改完毕再解锁。

  • func (*Cond) Wait
	func (c *Cond) Wait()

Wait会解锁c.L并进入等待状态,在被唤醒时,会重新锁定c.L

使用示例:

package main

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

func main() {
    m := sync.Mutex{}
    m.Lock()
    c := sync.NewCond(&m)
    go func() {
        m.Lock()
        defer m.Unlock()
        fmt.Println("3. goroutine is owner of lock")
        time.Sleep(2 * time.Second)
        c.Broadcast() //唤醒所有等待的 Wait
        fmt.Println("4. goroutine will release lock soon (deffered Unlock)")
    }()
    fmt.Println("1. main goroutine is owner of lock")
    time.Sleep(1 * time.Second)
    fmt.Println("2. main goroutine is still lockek")
    c.Wait()
    m.Unlock()
    fmt.Println("Done")
}

Map

并发安全的map

  • type Map
type Map struct {
        // contains filtered or unexported fields
}
  • func (*Map) Delete
	func (m *Map) Delete(key interface{})

删除一个键值。

  • func (*Map) Load
	func (m *Map) Load(key interface{}) (value interface{}, ok bool)

加载方法,也就是提供一个键key,查找对应的值value,如果不存在,通过ok反映。

  • func (*Map) LoadOrStore
	func (m *Map) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool)

LoadOrStore返回键的现有值(如果存在)。否则,它将存储并返回给定的值。如果已加载该值,则加载的结果为true,如果已存储,则为false

  • func (*Map) Range
	func (m *Map) Range(f func(key, value interface{}) bool)

for … range map是内建的语言特性,所以没有办法使用for range遍历sync.Map, 但是可以使用它的Range方法,通过回调的方式遍历。

  • func (*Map) Store
	func (m *Map) Store(key, value interface{})

更新或者新增一个entry

使用示例:

package main

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

func main() {
    var m sync.Map
    for i := 0; i < 3; i++ {
        go func(i int) {
            for j := 0; ; j++ {
                m.Store(i, j)
            }
        }(i)
    }
    for i := 0; i < 10; i++ {
        m.Range(func(key, value interface{}) bool {
            fmt.Printf("%d: %d\t", key, value)
            return true
        })
        fmt.Println()
        time.Sleep(time.Second)
    }
}

Pool

Pool用于存储临时对象,它将使用完毕的对象存入对象池中,在需要的时候取出来重复使用,目的是为了避免重复创建相同的对象造成GC负担过重。其中存放的临时对象随时可能被GC回收掉(如果该对象不再被其它变量引用)。

Pool中取出对象时,如果Pool中没有对象,将返回nil,但是如果给Pool.New字段指定了一个函数的话,Pool将使用该函数创建一个新对象返回。

Pool可以安全的在多个例程中并行使用,但Pool并不适用于所有空闲对象,Pool应该用来管理并发的例程共享的临时对象,而不应该管理短寿命对象中的临时对象,因为这种情况下内存不能很好的分配,这些短寿命对象应该自己实现空闲列表。

切记,Pool在开始使用之后,不能再被复制。

  • type Pool
type Pool struct {

        // New optionally specifies a function to generate
        // a value when Get would otherwise return nil.
        // It may not be changed concurrently with calls to Get.
        New func() interface{}
        // contains filtered or unexported fields
}
  • func (*Pool) Get
	func (p *Pool) Get() interface{}

从临时对象池中取出对象

  • func (*Pool) Put
	func (p *Pool) Put(x interface{})

向临时对象池中存入对象

使用示例:

package main

import (
    "fmt"
    "sync"
)

func main() {
    p := &sync.Pool{
        New: func() interface{} {
            return 0
        },
    }

    a := p.Get().(int)
    p.Put(100)
    b := p.Get().(int)
    fmt.Println(a, b)
}

原子操作

原子操作即是进行过程中不能被中断的操作。针对某个值的原子操作在被进行的过程中,CPU绝不会再去进行其他的针对该值的操作。 为了实现这样的严谨性,原子操作仅会由一个独立的CPU指令代表和完成。

sync/atomic 中,提供了一些原子操作,包括加法(Add)、比较并交换(Compare And Swap,简称 CAS)、加载(Load)、存储(Store)和交换(Swap)。

  • 加法操作
    提供了32/64位有符号与无符号加减操作
	var i int64
	atomic.AddInt64(&i, 1)
	fmt.Println("i = i + 1 =", i)
	atomic.AddInt64(&i, -1)
	fmt.Println("i = i - 1 =", i
  • 比较并交换
    CAS: Compare And Swap

如果addrold相同,就用new代替addr

	func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)

例如:

	var a int32 = 1
	var b int32 = 2
	var c int32 = 3
	ok := atomic.CompareAndSwapInt32(&a, a, b)
	fmt.Printf("ok = %v, a = %v, b = %v\n", ok, a, b)
	ok = atomic.CompareAndSwapInt32(&a, c, b)
	fmt.Printf("ok = %v, a = %v, b = %v, c=%v\n", ok, a, b, c)

输出:

ok = true, a = 2, b = 2
ok = false, a = 2, b = 2, c = 3
  • 交换

不管旧值与新值是否相等,都会通过新值替换旧值,返回的值是旧值。

	func SwapInt32(addr *int32, new int32) (old int32)

例如:

	var x int32 = 1
	var y int32 = 2
	old := atomic.SwapInt32(&x, y)
	fmt.Println(x, old)

输出:2 1

  • 加载
    当读取该指针指向的值时,CPU不会执行任何其它针对此值的读写操作
	func LoadInt32(addr *int32) (val int32)

例如:

	var x1 int32 = 1
	y1 := atomic.LoadInt32(&x)
	fmt.Println("x1, y1:", x1, y1)
  • 存储

加载逆向操作。

例如:

	var xx int32 = 1
	var yy int32 = 2
	atomic.StoreInt32(&yy, atomic.LoadInt32(&xx))
	fmt.Println(xx, yy)
  • 原子类型

sync/atomic中添加了一个新的类型Value。 例如:

	v := atomic.Value{}
	v.Store(1)
	fmt.Println(v.Load())

通道Channel

Go语言的并发模型是CSP,CSP 是 Communicating Sequential Process 的简称,中文可以叫做通信顺序进程,是一种并发编程模型,由Tony Hoare1977年提出。
简单来说是实体之间通过发送消息进行通信,这里发送消息时使用的就是通道,或者叫ChannelGoroutine对应并发实体。
Go 语言中的通道(channel)是一种特殊的类型。通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。

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所有的数据都发送完毕的时候才需要关闭通道。通道是可以被垃圾回收机制回收的,它和关闭文件是不一样的,在结束操作之后关闭文件是必须要做的,但关闭通道不是必须的。

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

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

Channel分类

  • 无缓冲的Channel
    发送与接受同时进行。如果没有Goroutine读取Channel(<-Channel),发送者(Channel<-x)会一直阻塞。
    在这里插入图片描述
func main() {
    ch := make(chan int)
    ch <- 10
    fmt.Println("发送成功")
}

上面这段代码能够通过编译,但是执行的时候会出现以下错误:

    fatal error: all goroutines are asleep - deadlock!

    goroutine 1 [chan send]:
    main.main()
            .../src/github.com/pprof/studygo/day06/channel02/main.go:8 +0x54

为什么会出现deadlock错误呢?

因为我们使用ch := make(chan int)创建的是无缓冲的通道,无缓冲的通道只有在有人接收值的时候才能发送值。就像你住的小区没有快递柜和代收点,快递员给你打电话必须要把这个物品送到你的手中,简单来说就是无缓冲的通道必须有接收才能发送。

上面的代码会阻塞在ch <- 10这一行代码形成死锁,那如何解决这个问题呢?

一种方法是启用一个goroutine去接收值,例如:

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

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

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

  • 有缓冲的Channel
    解决上面问题的方法还有一种就是使用有缓冲区的通道。
    发送与接受并非同时进行。当队列为空,接受者阻塞;队列满,发送者阻塞。
    在这里插入图片描述
    可以在使用make函数初始化通道的时候为其指定通道的容量,例如:
func main() {
    ch := make(chan int, 1) // 创建一个容量为1的有缓冲区通道
    ch <- 10
    fmt.Println("发送成功")
}

只要通道的容量大于零,那么该通道就是有缓冲的通道,通道的容量表示通道中能存放元素的数量。就像你小区的快递柜只有那么个多格子,格子满了就装不下了,就阻塞了,等到别人取走一个快递员就能往里面放一个。

我们可以使用内置的len函数获取通道内元素的数量,使用cap函数获取通道的容量,虽然我们很少会这么做。

Select

  • 每个case都必须是一个通信
  • 所有channel表达式都会被求值
  • 如果没有default语句,select将阻塞,直到某个通信可以运行
  • 如果多个case都可以运行,select会随机选择一个执行

随机选择

select特性之一:随机选择,下面会随机打印不同的case结果。 例如:

ch := make(chan int, 1)
ch <- 1
select {
case <-ch:
	fmt.Println("ch 1")
case <-ch:
	fmt.Println("ch 2")
default:
	fmt.Println("ch default")
}

假设chan中没有值,有可能引发死锁。

例如: 下面执行后会引发死锁。

ch := make(chan int, 1)
select {
case <-ch:
	fmt.Println("ch 1")
case <-ch:
	fmt.Println("ch 2")
}

此时可以加上default即可解决。

default:
	fmt.Println("ch default")

另外,还可以添加超时。

timeout := make(chan bool, 1)
go func() {
	time.Sleep(2 * time.Second)
	timeout <- true
}()
ch := make(chan int, 1)

select {
case <-ch:
	fmt.Println("ch 1")
case <-timeout:
	fmt.Println("timeout 1")
case <-time.After(time.Second * 1):
	fmt.Println("timeout 2")
}

检查chan

select+defaul方式来确保channel是否满

ch := make(chan int, 1)
ch <- 1
select {
case ch <- 1:
	fmt.Println("channel value is ", <-ch)
	fmt.Println("channel value is ", <-ch)
default:
	fmt.Println("channel blocking")
}

如果要调整channel大小,可以在make的时候改变size,这样就可以在case中往channel继续写数据。

选择循环

当多个channel需要读取数据的时候,就必须使用for+select

例如:下面例子需要从两个channel中读取数据,当从channel1中数据读取完毕后,会像signal channel中输入stop,此时终止for+select

func f1(c chan int, s chan string) {
	for i := 0; i < 10; i++ {
		time.Sleep(time.Second)
		c <- i
	}
	s <- "stop"
}

func f2(c chan int, s chan string) {
	for i := 20; i >= 0; i-- {
		time.Sleep(time.Second)
		c <- i
	}
	s <- "stop"
}

func main() {
	c1 := make(chan int)
	c2 := make(chan int)
	signal := make(chan string, 10)

	go f1(c1, signal)
	go f2(c2, signal)
LOOP:
	for {
		select {
		case data := <-c1:
			fmt.Println("c1 data is ", data)
		case data := <-c2:
			fmt.Println("c2 data is ", data)
		case data := <-signal:
			fmt.Println("signal is ", data)
			break LOOP
		}
	}
}

简单应用

通过sync实现单例

package singleton

import (
    "sync"
)

type singleton struct {
}

var instance *singleton
var once sync.Once

func GetInstance() *singleton {
    once.Do(func() {
        instance = &singleton{}
    })
    return instance
}

访问多个url

package main

import (
    "fmt"
    "io/ioutil"
    "log"
    "net/http"
    "sync"
)

func main() {
    urls := []string{
        "https://github.com",
        "https://golang.org/",
        "https://golang.org/doc/",
    }
    jsonResponses := make(chan string)

    var wg sync.WaitGroup

    wg.Add(len(urls))

    for _, url := range urls {
        go func(url string) {
            defer wg.Done()
            res, err := http.Get(url)
            if err != nil {
                log.Fatal(err)
            } else {
                defer res.Body.Close()
                body, err := ioutil.ReadAll(res.Body)
                if err != nil {
                    log.Fatal(err)
                } else {
                    jsonResponses <- string(body)
                }
            }
        }(url)
    }

    go func() {
        for response := range jsonResponses {
            fmt.Println(response)
        }
    }()

    wg.Wait()
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值