第十七章 多线程

17.1 线程与进程

17.1.1 进程

计算机程序有静态动态的区别。静态的计算机程序就是存储在磁盘上的可执行二进制(或其他类型)文件,而动态的计算机程序就是将这些可执行文件加载到内存中并被操作系统调用,这些动态的计算机程序被称为一个进程,也就是说,进程是活跃的,只有可执行程序被调入内存中才称为进程每个进程都拥有自己的地址空间、内存、数据栈以及其他用于跟踪执行的辅助数据。操作系统会管理系统中所有进程的执行,并为这些进程合理地分配时间。进程可以通过派生(fork或spawn)新的进程来执行其他任务,不过由于每个新进程也都拥有自己的内存和数据栈等,所以只能采用进程间通信(IPC)的方式共享信息

17.1.2 线程

线程(有时候也被称为轻量级进程)与进程类似,不过线程是在同一个进程下执行的,并共享同一个上下文。也就是说,线程属于进程,而且线程必须要依赖进程才能执行。一个进程可以包含一个或多个线程。

线程包括开始、执行和结束三部分。它有一个指令指针,用于记录当前运行的上下文,当其他线程运行时,当前线程有可能被抢占(中断)或临时挂起(睡眠)。

一个进程中的各个线程与主线程共享同一片数据空间,因此相对于独立的进程而言,线程间的信息共享和通信更容易。线程一般是以并发方式执行的,正是由于这种并行和数据共享机制,使得多任务间的协作成为可能。当然,在单核CPU的系统中,并不存在真正的并发运行,所以线程的执行实际上还是同步执行的,只是系统会根据调度算法在不同的时间安排某个线程在CPU上执行一小会儿,然后就会让其他的线程在CPU上再执行一会儿,通过这种多个线程之间不断切换的方式让多个线程交替执行。因此,从宏观上看,即使在单核CPU的系统上仍然看着像多个线程并发运行一样。

当然,多线程之间共享数据并不是没有风险。如果两个或多个线程访问了同一片数据,由于数据访问顺序不同,可能导致结果的不一致。这种情况通常称为静态条件(static condition),幸运的是大多数线程库都有一些机制让共享内存区域的数据同步,也就是说,当一个线程访问这片内存区域时这片内存区域暂时被锁定,其他的线程只能等待这片内存区域解锁后再访问。

要注意的是,线程的执行时间是不平等的,例如,有6个线程,6s的CPU执行时间,并不是为这6个线程平均分配CPU执行时间(每个线程1s),而是根据线程中具体的执行代码分配CPU计算时间。例如,在调动一些函数时,这些函数会在完成之前保存阻塞状态(阻止其他线程获得CPU执行时间),这样这些函数就会长时间占用CPU资源,通常来讲,系统在分配CPU计算时间时更倾向于这些贪婪的函数。

17.2 Python 与线程

17.2.1 使用单线程执行程序

from time import sleep, ctime

def fun1():
    print('开始运行fun1:', ctime())
    # 休眠4秒
    sleep(4)
    print('fun1运行结束:', ctime())
    
def fun2():
    print('开始运行fun2:', ctime())
    # 休眠2秒
    sleep(2)
    print('fun2运行结束:', ctime())

def main():
    # 在单线程中调用fun1函数和fun2函数
    print('开始运行时间:', ctime())
    print('---------------------------------')
    fun1()
    print('---------------------------------')
    fun2()
    print('---------------------------------')
    print('结束运行时间:', ctime())

if __name__ == '__main__':
    main()

在这里插入图片描述

17.2.2 使用多线程执行程序

Python 提供了很多内建模块用于支持多线程 _thread。要注意的是,在Python2.x时,这个模块称为thread,从Python3.x 开始,thread更名为 _thread,也就是在 thread前加一个下画线(_)。

使用 _thread模块中的start_new_thread函数会直接开启一个线程,该函数的第1个参数需要指定一个函数,可以把这个函数称为线程函数,当线程启动时会自动调用这个函数。start_new_thread函数的第2个参数是给线程函数传递的参数,必须是元组类型

import _thread as thread
from time import sleep,ctime

def fun1():
    print('开始运行fun1:', ctime())
    sleep(4)
    print('fun1运行结束:', ctime())

def fun2():
    print('开始运行fun2:', ctime())
    sleep(2)
    print('fun2运行结束:', ctime())

def main():
    print('开始运行时间:', ctime())
    thread.start_new_thread(fun1, ())
    thread.start_new_thread(fun2, ())
    sleep(6)
    print('结束运行时间:', ctime())

if __name__ == '__main__':
    main()

在这里插入图片描述
从程序的运行结果可以看出,在第1个线程运行fun1函数的过程中,会使用第2个线程运行fun2函数。这是因为在 fun1函数中调用了sleep函数休眠了4s,当程序休眠时,会释放CPU的计算资源,这时fun2函数乘虚而入,抢占了fun1函数的CPU计算资源。而 fun2函数只通过 sleep函数休眠了2s,所以当fun2函数执行完,fun1函数还没有休眠完。4s 后,fun1函数继续执行,这时已经没有要执行的函数与 funl函数抢CPU计算资源,所以funl函数会顺利地执行完。在main函数中使用sleep函数休眠6s,等待fun1函数和 fun2函数都执行完,再结束程序。

17.2 3 为线程函数执行参数

通过start_new_thread 函数的第2个参数可以为线程函数传递参数,该参数类型必须是元组。

import random
from time import sleep
import _thread as thread

# 线程函数,其中a和b是通过start_new_thread传入的参数
def fun(a,b):
    print(a,b)
    # 随机休息一段时间(1-4s)
    sleep(random.randint(1,5))

# 启动8个线程
for i in range(8):
    # 为每一个线程函数传入2和元组类型的值
    thread.start_new_thread(fun, (i + 1,'a' * (i + 1)))
input()

在这里插入图片描述

由于每个线程函数的休眠时间可能都不相同,所以随机输出了这个结果,每次运行程序,输出的结果是不一样的。

最后使用input函数从终端采集了一个字符串,其实程序对这个从终端输入的字符串并不关心,只是让程序暂停而已。如果程序启动线程后不暂停,还没等线程函数运行,程序就结束了,这样线程函数将永远不会执行了

在这里插入图片描述

17.2.4 线程和锁

在前面的代码中使用多线程运行线程函数,在 main函数的最后需要使用sleep函数让程序处于休眠状态,或使用input函数从终端采集一个字符串,目的是让程序暂停,其实这些做法的目的只有一个,即在所有的线程执行完之前,阻止程序退出。因为程序无法感知是否有线程正在执行,以及是否所有的线程函数都执行完毕。因此,只能采用这些手段让程序暂时不退出。如果了解了锁的概念,就会觉得这些做法十分低级。

这里的锁并不是将程序锁住不退出,而是通过锁可以让程序了解是否还有线程函数没执行完,而且可以做到当所有的线程函数执行完后,程序会立刻退出,无须任何等待。

锁的使用分为创建锁获取锁释放锁。完成这三个功能需要_thread模块中的一个函数和两个方法,allocate_lock 函数用于创建锁对象,然后使用锁对象的acquire方法获取锁,如果不需要锁了,可以使用锁对象的release方法释放锁;如果要判断锁是否被释放,可以使用锁对象的 locked方法。

import _thread as thread
from time import sleep, ctime

# 线程函数,index是一个整数类型的索引,sec是休眠时间(单位:秒),lock是锁对象
def fun(index, sec,lock):
    print('开始执行', index,'执行时间:',ctime())
    # 休眠
    sleep(sec)
    print('执行结束',index,'执行时间:',ctime())
    # 释放锁对象
    lock.release()

def main():
    # 创建第1个锁对象
    lock1 = thread.allocate_lock()
    # 获取锁(相当于把锁锁上)
    lock1.acquire()
    # 启动第1个线程,并传入第1个锁对象,10是索引,4是休眠时间,lock1是锁对象
    thread.start_new_thread(fun, 
            (10, 4, lock1))
    # 创建第2个锁对象       
    lock2 = thread.allocate_lock()
    # 获取锁(相当于把锁锁上)
    lock2.acquire()
    # 启动第2个线程,并传入第2个锁对象,20是索引,2是休眠时间,lock2是锁对象
    thread.start_new_thread(fun, 
            (20, 2, lock2))
    # 使用while循环和locked方法判断lock1和lock2是否被释放
    # 只要有一个锁没有释放,while就不会退出
    while lock1.locked() or lock2.locked():
        pass
        
if __name__ == '__main__':
    main()

在这里插入图片描述

17.3 高级线程模块threading

在threading模块中有一个非常重要的Thread类,该类的实例表示一个执行线程的对象。_thread 模块可以看作线程的面向过程版本,而Thread类可以看作线程的面向对象版本。

17.3.1 Thread 类与线程函数

在前面的例子中使用锁(lock)检测线程是否释放,以及使用锁可以保证所有的线程函数都执行完毕再往下执行。如果使用Thread类处理线程就方便多了,可以直接使用Thread对象的join方法等待线程函数执行完毕再往下执行,也就是说,在主线程(main 函数)中调用Thread对象的join方法,并且Thread对象的线程函数没有执行完毕,主线程会处于阻塞状态

使用Thread类也很简单,首先需要创建Thread类的实例,通过Thread类构造方法的target关键字参数执行线程函数,通过args关键字参数指定传给线程函数的参数。然后调用Thread对象的start方法启动线程。

import threading
from time import sleep, ctime

# 线程函数,index表示整数类型的索引,sec表示休眠时间,单位:秒
def fun(index, sec):
    print('开始执行', index, ' 时间:', ctime())
    sleep(sec)
    print('结束执行', index, '时间:', ctime())
    
def main():
    # 创建第1个Thread对象,通过target关键字指定线程函数,传入索引10和休眠时间(4s)
    thread1 = threading.Thread(target=fun,
            args=(10, 4))
    # 启动第1个线程
    thread1.start()
    # 创建第2个Thread对象,通过target关键字指定线程函数,传入索引20和休眠时间(2s)
    thread2 = threading.Thread(target=fun,
            args=(20, 2))
    # 启动第2个线程
    thread2.start()
    # 等待第1个线程函数执行完毕
    thread1.join()
    # 等待第2个线程函数执行完毕
    thread2.join()

if __name__ == '__main__':
    main()

从输出结果可以看出,通过Thread对象启动的线程只需要使用join方法就可以保证让所有的线程函数都执行完再往下执行,这要比 _thread 模块中的锁方便得多,起码不需要在线程函数中释放锁了。

17.3.2 Thread 类与线程对象

Thread类构造方法的target关键字参数不仅可以是一个函数,还可以是一个对象,可以称这个对象为线程对象。其实线程调用的仍然是函数,只是这个函数用对象进行了封装。

线程对象对应的类需要有一个可以传入线程函数和参数的构造方法,而且在类中还必须有一个名为“_call_”的方法。当线程启动时,会自动调用线程对象的“_call_ ”方法,然后在该方法中会调用线程函数。

import threading
from time import sleep, ctime

# 线程对象对应的类
class MyThread(object):
    # func表示线程函数,args表示线程函数的参数
    def __init__(self, func, args):
        # 将线程函数与线程函数的参数赋给当前的类成员的变量
        self.func = func
        self.args = args
    # 线程启动时会调用该方法
    def __call__(self):
        # 调用线程函数,并将元组类型的参数值分解为单个的参数值传入线程函数
        self.func(*self.args)

# 线程函数   
def fun(index, sec):
    print('开始执行', index, ' 时间:', ctime())
    # 延迟 sec 秒
    sleep(sec)
    print('结束执行', index, '时间:', ctime())
def main():
    print('执行开始时间:', ctime())
    # 创建第1个线程,通过target关键字指定线程对象(MyThread),延迟4s
    thread1 = threading.Thread(target = MyThread(fun,(10, 4)))
    # 启动第1个线程
    thread1.start()
    # 创建第2个线程,通过target关键字指定线程对象(MyThread),延迟2s
    thread2 = threading.Thread(target = MyThread(fun,(20, 2)))
    # 启动第2个线程
    thread2.start()
    # 创建第3个线程,通过target关键字指定线程对象(MyThread),延迟1s
    thread3 = threading.Thread(target = MyThread(fun,(30, 1)))
    # 启动第3个线程
    thread3.start()

    #等待线程函数执行完毕
    thread1.join()
    thread2.join()
    thread3.join()
    print('所有的线程函数已经执行完毕:', ctime())

if __name__ == '__main__':
    main()

在这里插入图片描述

17.3.3 从 Thread 类继承

为了更好地对与线程有关的代码进行封装,可以从Thread类派生一个子类,然后将与线程有关的代码都放到这个类中。Thread 类的子类的使用方法与Thread相同。从Thread类继承最简单的方式是在子类的构造方法中通过super函数调用父类的构造方法,并传入相应的参数值。

import threading
from time import sleep, ctime

# 从Thread类派生子类
class MyThread(threading.Thread):
    # 重写父类的方法,其中func是线程函数,args是传入线程函数的参数,name是线程名
    def __init__(self, func, args, name=''):
        # 调用父类的构造方法,并传入相应的参数值
        super().__init__(target=func, name=name,
                 args=args)
    # 重写父类的run方法
    def run(self):
        self._target(*self._args)

# 线程函数
def fun(index, sec):
    print('开始执行', index, '时间:', ctime())
    # 休眠
    sleep(sec)
    print('执行完毕', index, '时间:', ctime())

def main():
    print('开始:', ctime())
    # 创建第1个线程名,并指定线程名为"线程1"
    thread1 = MyThread(fun,(10,4),'线程1')
    # 创建第2个线程名,并指定线程名为"线程2"
    thread2 = MyThread(fun,(20,2),'线程2')
    # 等价于从一个主进程衍生出了多个线程,主进程继续执行
    thread1.start()
    thread2.start()
    # 主进程继续执行,输出信息顺序固定
    print(thread1.name)
    print(thread2.name)
    # 等待线程结束
    thread1.join()
    thread2.join()
    # 主进程执行后续步骤
    print('结束:', ctime())

if __name__ == '__main__':
    main()

在这里插入图片描述
在调用Thread类的构造方法时需要将线程函数、参数等值传入构造方法,其中name表示线程的名字,如果不指定这个参数,默认的线程名字格式为Thread-1. Thread-2。 每一个传入构造方法的参数值,在Thread类中都有对应的成员变量保存这些值,这些成员变量都以下画线(_ ) 开头,如 _target、_args 等(这一点从Thread类的构造方法中就可以看出)。在run方法中需要使用这些变量调用传入的线程函数,并为线程函数传递参数。

# Thread类的构造方法
def__init__(self,group=None,target=None,name=None,args=(),kwargs=None, *, daemon=None):
	...
	self._target = target
	self._name = str (name or_ newname () )
	self._args = args
	self._kwargs = kwargs

这个run方法不一- 定要在MyThread类中重写,因为Thread类已经有默认的实现了,不过如果想扩展一下这个方法,也可以进行重写,并加入自己的代码。

#Thread类的run方法
def run(self):
	try:
		if self._target:
		self._target(*self._args, **self._kwargs)
	finally:
		del self._target, self._args, self._kwargs

17.4 线程同步

多线程的目的就是让多段程序并发运行,但在一些情况下,让多段程序同时运行会造成很多麻烦,如果这些并发运行的程序还共享数据,则有可能造成脏数据以及其他数据不一致的后果。这里的脏数据是指在多段程序同时读写一个或一组变量时,由于读写顺序的问题导致的与期望值不一样的后果。

例如,有一个整数变量n,初始值为1,现在要为该变量加1,然后输出该变量的值,目前有两个线程(Thread1和Thread2 )做同样的工作。当Thread1为变量n加1后,这时CPU计算的时间恰巧被Thread2夺走,在执行Thread2的线程函数时又对变量n加1,所以目前n被加了两次1,变成了3。这时不管是继续执行Thread2,还是接着执行Thread1,输出的n都会等于3。这也就意味着n等于2的值没有输出,如果正好在n等于2时需要做更多的处理,那么这些工作都不会按预期完成了,因为这时n已经等于3了。把这个变量当前的值称为脏数据,也就是说n原本应该等于2,而现在却等于3。

17.4.1 线程锁

线程锁的目的是将一段代码锁住,一旦获得了锁权限,除非释放线程锁,否则其他任何代码都无法再次获得锁权限。

为了使用线程锁,首先需要创建Lock类的实例,然后通过Lock对象的acquire方法获取锁权限,当需要完成原子操作的代码段执行完后,再使用Lock对象的release方法释放锁,其他代码就可以再次获得这个锁权限。要注意的是,锁对象要放到线程函数的外面作为一个全局变量,这样所有的线程函数实例都可以共享这个变量,如果将锁对象放到线程函数内部,那么这个锁对象就变成了局部变量,多个线程函数实例使用的是不同的锁对象,所以仍然不能有效保护原子操作的代码

from atexit import register
import random
from threading import Thread, Lock, currentThread
from time import sleep, ctime

# 创建线程锁对象
lock = Lock()
def fun():
    # 获取线程锁权限
    lock.acquire()
    # for循环已经变成了原子操作
    for i in range(5):
        print('Thread Name','=',currentThread().name,'i','=',i)
        # 休眠一段时间(1-4s)
        sleep(random.randint(1,5))
    # 释放线程锁,其他线程函数就可以获得这个线程锁的权限了
    lock.release()

# 通过循环创建并启动3个线程
def main():
    for i in range(3):
        Thread(target=fun).start()

# 当程序结束时会调用这个函数,@register装饰器用于对原有函数做一些扩展功能
@register
def exit():
    print('线程执行完毕:', ctime())

if __name__ == '__main__':
    main()

在这里插入图片描述
不加锁时测试:
在这里插入图片描述

在这里插入图片描述

17.4.2 信号量

线程锁非常容易理解和实现,也很容易决定何时需要它们,然而,如果
情况更加复杂,就可能需要更强大的技术配合线程锁一起使用。

信号量是一个计数器,用于记录资源的消耗情况。当资源消耗时递减,当资源释放时递增可以认为信号量代表资源是否可用。消耗资源使计数器递减的操作习惯上称为P,当一个线程对一个资源完成操作时,该资源需要返回资源池中,这个操作一般称为V。Python语言统一了所有的命名,使用与线程锁同样的方法名消耗和释放资源。acquire 方法用于消耗资源,调用该方法计数器会减1;release 方法用于释放资源,调用该方法计数器会加1。使用信号量首先要创建BoundedSemaphore类的实例,并且通过该类的构造方法传入计数器的最大值,然后就可以使用BoundedSemaphore对象的acquire方法和release方法获取资源(计数器减1)和释放资源(计数器加1)。

from threading import BoundedSemaphore

MAX = 3
# 创建信号量对象,并设置了计数器的最大值(也是资源的最大值),计数器不能超过这个值
semaphore = BoundedSemaphore(MAX)
# 输出当前计数器的值为:3
print(semaphore._value)

# 获取资源计数器减1
semaphore.acquire()
# 输出当前计数器的值为:2
print(semaphore._value)
# 获取资源计数器减1
semaphore.acquire()
# 输出当前计数器的值为:1
print(semaphore._value)
# 获取资源计数器减1
semaphore.acquire()
# 输出当前计数器的值为:0
print(semaphore._value)
# 当计数器为0时,不能再获取资源,所以acquire方法返回False
print(semaphore.acquire(False))
print(semaphore._value)
# 输出当前计数器的值为:0

# 释放资源计数器加1
semaphore.release()
# 输出当前计数器的值为:1
print(semaphore._value)
# 释放资源计数器加1
semaphore.release()
# 输出当前计数器的值为:2
print(semaphore._value)
# 释放资源计数器加1
semaphore.release()
# 输出当前计数器的值为:3
print(semaphore._value)
# 抛出异常,当计数器达到最大时,不能再次释放资源,否则会抛出异常
semaphore.release()

在这里插入图片描述
要注意的是信号量对象的acquire方法与release 方法。当资源枯竭(计数器为0)时调用acquire方法会有两种结果。第1种是acquire方法的参数值为True或不指定参数时,acquire 方法会处于阻塞状态,直到使用release方法释放资源后,acquire方法才会往下执行。如果acquire方法的参数值为False,则当计数器为0时调用acquire方法并不会阻塞,而是直接返回False,表示未获得资源;如果成功获得资源,则返回True。

release方法在释放资源时,如果计数器已经达到了最大值(本例是3),则直接抛出异常,表示已经没有资源释放了。

示例:通过信号量和线程锁模拟了一个糖果机补充糖果和用户取得糖果的过程,糖果机有5个槽,如果发现某个槽没有糖果了,则需要补充新的糖果。当5个槽都装满时,无法补充新的糖果。如果5个槽都是空的,顾客无法购买糖果。为了方便,本例假设顾客一次会购买整个槽的糖果,每次补充整个槽的糖果。

from atexit import register
from random import randrange
from threading import BoundedSemaphore, Lock, Thread
from time import sleep, ctime

# 创建线程锁
lock = Lock()
# 定义糖果机的槽数,也是信号量计数器的最大值
MAX = 5
# 创建信号量对象,并指定计数器的最大值
candytray = BoundedSemaphore(MAX)

# 给糖果机的槽补充新的糖果(每次补充一个槽)
def refill():
    # 获取线程锁,并将糖果的操作变成原子操作
    lock.acquire()
    print('重新添加糖果...', end=' ')
    try:
        # 为糖果机的槽补充糖果(计数器加1)
        candytray.release()
    except ValueError:
        print('糖果机都满了,无法添加')
    else:
        print('成功添加糖果')
    # 释放线程锁
    lock.release()

# 顾客购买糖果
def buy():
    # 获取线程锁,并将糖果的操作变成原子操作
    lock.acquire()
    print('购买糖果...', end=' ')
    # 顾客购买糖果(计数器减1),如果购买失败(5个槽都没糖果了),返回False
    if candytray.acquire(False):
        print('成功购买糖果')
    else:
        print('糖果机为空,无法购买糖果')
    # 释放线程锁
    lock.release()

# 产生多个补充糖果的动作
def producer(loops):
    for i in range(loops):
        refill()
        sleep(randrange(3))

# 产生多个购买糖果的动作
def consumer(loops):
    for i in range(loops):
        buy()
        sleep(randrange(3))

def main():
    print('开始:', ctime())
    #产生2-5个随机数
    nloops = randrange(2, 6)
    print('糖果机共有%d个槽!' % MAX)
    # 开始一个线程,用于执行consumer函数
    Thread(target=consumer, args=(randrange(nloops, nloops+MAX+2),)).start() 
    # 开始一个线程,用于执行producer函数
    Thread(target=producer, args=(nloops,)).start() 

@register
def exit():
    print('程序执行完毕:', ctime())

if __name__ == '__main__':
    main()

在这里插入图片描述

17.5 生产者-消费者问题与 queue 模块

使用线程锁以及队列来模拟一个典型的案例:生产者-消费者模型。在这个场景下,商品或服务的生产者生产商品,然后将其放到类似队列的数据结构中,生产商品的时间是不确定的,同样消费者消费生产者生产的商品的时间也是不确定的。

这里使用queue模块来提供线程间通信的机制,也就是说,生产者和消费者共享一个队列。生产者生产商品后,会将商品添加到队列中。消费者消费商品,会从队列中取出一个商品。由于向队列中添加商品和从队列中获取商品都不是原子操作,所以需要使用线程锁将这两个操作锁住。

from random import randrange
from time import sleep,time, ctime
from threading import Lock, Thread
from queue import Queue

# 创建线程锁对象
lock = Lock()
# 从Thread派生的子类
class MyThread(Thread):
    def __init__(self, func, args):
        super().__init__(target = func, args = args)

# 向队列添加商品
def writeQ(queue):
    # 获取线程锁
    lock.acquire()
    # 添加商品
    print('生产了一个对象,并将其添加到队列中', end='  ')
    queue.put('商品')
    print("队列尺寸", queue.qsize())
    # 释放线程锁
    lock.release()

# 从队列中获取商品
def readQ(queue):
    # 获取线程锁
    lock.acquire()
    # 获取商品
    val = queue.get(1)
    print('消费了一个对象,队列尺寸:', queue.qsize())
    # 释放线程锁
    lock.release()

# 生成若干个生产者
def writer(queue, loops):
    for i in range(loops):
        writeQ(queue)
        sleep(randrange(1, 4))

# 生成若干个消费者
def reader(queue, loops):
    for i in range(loops):
        readQ(queue)
        sleep(randrange(2, 6))

funcs = [writer, reader]
nfuncs = range(len(funcs))

def main():
    nloops = randrange(2, 6)
    q = Queue(32)

    threads = []
    # 创建2个线程运行writer函数和reader函数
    for i in nfuncs:
        t = MyThread(funcs[i], (q, nloops))
        threads.append(t)
    # 开始线程
    for i in nfuncs:
        threads[i].start()
    # 等待两个线程结束
    for i in nfuncs:
        threads[i].join()

    print('所有的工作完成')

if __name__ == '__main__':
    main()

在这里插入图片描述

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值