Python 笔记(16)— 多线程、线程锁、线程池、线程数量、互斥锁、死锁、线程同步

主要介绍使用 threading 模块创建线程的 3 种方式,分别为:

  • 创建 Thread 实例函数
  • 创建 Thread 实例可调用的类对象
  • 使用 Thread 派生子类的方式

多线程是提高效率的一种有效方式,但是由于 CPython 解释器中存在 GIL 锁,因此 CPython 中的多线程只能使用单核。也就是说 Python 的多线程是宏观的多线程,而微观上实际依旧是单线程。

线程和进程之间有很多相似的地方,它们都是一个独立的任务。但是相比进程,线程要小的多。我们运行线程需要在进程中进行,而且线程和线程之间是共享内存的。相比进程的数据隔离,线程的安全性要更差一些。

1. Thread 实例函数

使用 threading 模块创建一个 Thread 的实例,传递给它一个函数。

import threading
import time

loops = [4, 2]


def loop(nloop, nsec):
    print 'start loop', nloop, 'at:', time.ctime()
    time.sleep(nsec)
    print 'end loop', nloop, 'at:', time.ctime()


def main():
    print 'start main at:', time.ctime()
    threads = []
    nloops = range(len(loops))
# 实例化Thread即调用Thread()与调用start_new_thread()最大区别是:新的线程不会立即开始
    for i in nloops:
        t = threading.Thread(target=loop, args=(i, loops[i]))
        threads.append(t)
        # t.daemon = True     python主程序只有在没有非守护线程的时候才会退出,设置# 线程是否随主线程退出而退出,默认为False
 
# 所有线程创建之后,一起调用start()函数启动,而不是创建一个调用一个
    for i in nloops:
        threads[i].start() 
        
# join()会等到线程结束,或者在给了timeout参数的时候,等到超时为止
# join() 的作用是让主线程等待直到该线程执行完
    for i in nloops:
        threads[i].join()   

    print 'end main at:', time.ctime()

if __name__ == "__main__":
    main()

代码输出如下:

'''
start main at: Sat Jul 21 22:27:35 2018
start loop 0 at: Sat Jul 21 22:27:35 2018
start loop 1 at: Sat Jul 21 22:27:35 2018
end loop 1 at: Sat Jul 21 22:27:37 2018
end loop 0 at: Sat Jul 21 22:27:39 2018
end main at: Sat Jul 21 22:27:39 2018
'''

常用线程方法:

# 如上所述,创建一个线程
t=Thread(target=func)

# 启动子线程
t.start()

# 阻塞子线程,待子线程结束后,再往下执行
t.join()

# 判断线程是否在执行状态,在执行返回True,否则返回False
t.is_alive()
t.isAlive()

# 设置线程是否随主线程退出而退出,默认为False
t.daemon = True
t.daemon = False

# 设置线程名
t.name = "My-Thread"

2. Thread 实例可调用的类对象

创建一个 Thread 的实例,传递给它一个可调用的类对象

import threading
import time

loops = [4, 2]

class ThreadFun(object):

    def __init__(self, func, args, name=''):
        self.name = name
        self.func = func
        self.args = args

    def __call__(self):
        apply(self.func, self.args)


def loop(nloop, nsec):
    print 'start loop', nloop, 'at:', time.ctime()
    time.sleep(nsec)
    print 'end loop', nloop, 'at:', time.ctime()

def main():
    print 'main is start at:', time.ctime()
    threads = []
    nloops = range(len(loops))

    for i in nloops:
        t = threading.Thread(target=ThreadFun(loop, (i, loops[i]), loop.__name__))
        # 该类在调用函数方面更加通用,并不局限于loop()函数
        threads.append(t)

    for i in nloops:
        threads[i].start()

    for i in nloops:
        threads[i].join()

    print 'main is end at:', time.ctime()

if __name__ == "__main__":
    main()

3. Thread 派生子类

除了用函数的方式,我们还可以用面向对象的方式来创建线程。这就需要我们手动继承 Thread 类,而且还需要实现其中的 run 方法,代码如下:

import time
from threading import Thread

class MyThread(Thread):
    def __init__(self):
        super().__init__()

    def run(self):
        time.sleep(1)
        print("我在运行")


t = MyThread()
t.start()
print("我是主线程")

Thread 派生出一个子类,创建一个这个子类的实例

import threading
import time


loops = [4, 2]


class MyThread(threading.Thread):

    def __init__(self, func, args, name=''):
        # super().__init__(name=name)	# # 线程的名字
        threading.Thread.__init__(self)
        self.name = name
        self.func = func
        self.args = args
	
	# run 等同于之前 target 指定的函数
    def run(self):
        apply(self.func, self.args)
        
   def test(self):
       print("this is test")

def loop(nloop, nsec):
    print 'start loop', nloop, 'at:', time.ctime()
    time.sleep(nsec)
    print 'end loop', nloop, 'at:', time.ctime()


def main():
    print 'main is start at:', time.ctime()
    threads = []
    nloops = range(len(loops))

    for i in nloops:
    	# 类似于 Thread(target=函数名) , 只会创建出一个线程
        t = MyThread(loop, [i, loops[i]], loop.__name__)	
        threads.append(t)
	
	# MyThread 类中没有 start 方法,继承的父类 调用 start 方法,会自动调用 run 方法
    for i in nloops:
        threads[i].start()

    for i in nloops:
        threads[i].join()

    print 'main is end at:', time.ctime()
	
	t.test()        # 这种方式不是多线程的方式!!!要在 run 方法里面调用 test 方法,才是多任务的方式
if __name__ == "__main__":
    main()

大家在用面向对象的方式,要注意类中除了 run 方法外,其他的方法,通过类的实例化去调用并不是多线程的方式。

4. 使用线程锁来解决资源竞争

import threading

lock = threading.Lock()
some_var = 0


class IncrementThread(threading.Thread):
    def run(self):
        global some_var
        lock.acquire()  #
        read_value = some_var
        print "some_var in %s is %d" % (self.name, read_value)
        some_var = read_value + 1
        print "some_var in %s after increment is %d" % (self.name, some_var)
        lock.release()


def use_increment_thread():
    threads = []
    for i in range(50):
        t = IncrementThread()
        threads.append(t)
        t.start()
    for t in threads:
        t.join()
    print "After 50 modifications, some_var should have become 50"
    print "After 50 modifications, some_var is %d" % (some_var,)
    
if __name__ == "__main__":
    use_increment_thread()

这里需要注意一点,我们两个函数/进程使用的是同一把锁,如果我们使用不同的锁还是会出现数据不安全的问题。

Python 提供的锁机制,是解决上面问题的方法之一。

某段代码只能单线程执行时,加上锁,其他线程等待,直到被释放后,其他线程再争锁,竞争到锁的线程执行代码,再释放锁,重复此过程,直到所有线程都走过一遍竞争到锁和释放锁的过程。

但是,再仔细想想,这已经是单线程顺序执行。就本案例而言,已经失去多线程的价值。并且,还带来了因为线程创建开销,浪费时间的副作用。除此之外,还有一个很大风险。

当程序中只有一把锁,通过 try...finally 还能确保不发生死锁。但是,当程序中启用多把锁,很容易发生死锁。

5. 线程池

池是用来保证计算机硬件安全的情况下,最大限度地利用计算机,它降低了程序的运行效率,但是保证了计算机硬件的安全,从而让你写的程序能够正常运行。

  • 同步:提交任务之后原地等待任务的返回结果,期间不做任何事
  • 异步:提交任务之后不等待任务的返回结果,执行继续往下执行

ThreadPoolExecutor 让线程的使用更加简单方便,减小了线程创建/销毁的资源损耗,无需考虑线程间的复杂同步,方便主线程与子线程的交互。

from concurrent.futures import ThreadPoolExecutor
import time

def get_html(times):
    time.sleep(times)
    print("get page {} success".format(times))
    return times

executor = ThreadPoolExecutor(max_workers=2)
task1 = executor.submit(get_html,(2))
task2 = executor.submit(get_html,(3))

#done方法用来判断某个人物是否完成
print(task1.done())
time.sleep(5)
print(task2.done())
print(task1.cancel()
#result方法可以获取task返回值
print(task1.result())

线程池是从 Python 3.2 才被加入标准库中的 concurrent.futures 模块,相比 threading 模块,该模块通过 submit 返回的是一个 future 对象,通过它可以获取某一个线程的任务执行状态或返回值,另外 futures 可以让多线程和多进程的编码接口一致,

from concurrent.futures import ThreadPoolExecutor
import time

# 括号内可以传数字 不传的话默认会开设当前计算机 cpu 个数进程
pool = ThreadPoolExecutor(5)    # 池子里面固定只有五个线程
"""
池子造出来之后 里面会固定存在五个线程
这个五个线程不会出现重复创建和销毁的过程
"""
def task(n):
    print(n)
    time.sleep(2)
    return n**n

# pool.submit(task, 1)    # 朝池子中提交任务  异步提交
# print("主")

def call_back(n):    # 回调处理数据的函数
    print('call_back>>>:',n.result())    # obj.result() 拿到的就是异步提交的任务的返回结果

t_list = []
for i in range(10):
    res = pool.submit(task, i)
    # print(res.result())   # result 方法   同步提交
    # res = pool.submit(task, i).add_done_callback(call_back)
    # 将 res 返回的结果 <Future at 0x100f97b38 state=running>,交给回电函数 call_back 处理
    # 即 res 做实参传给 call_back 函数
    t_list.append(res)


# 等待线程池中所有的任务执行完毕之后再继续往下执行
pool.shutdown()     # 关闭线程池  等待线程池中所有的任务运行完毕
for t in t_list:
    print(">>>", t.result())

因为开启线程需要消耗一些时间,所以有时候我们会使用线程池来减少开启线程花费的时间。线程池的操作定义在 concurrent.futures.ThreadPoolExecutor 类中,下面我们来看看线程池如何使用:

import time
import threading
from concurrent.futures import ThreadPoolExecutor


def func1():
    print(threading.current_thread().name, 'is running')


def func2():
    for i in range(3):
        time.sleep(1)
        print(threading.current_thread().name, 'is running')

pool = ThreadPoolExecutor(max_workers=2)
t1 = pool.submit(func2)
t2 = pool.submit(func1)

在代码中我们创建了一个容量为 2 的线程池,我们调用 pool.submit 函数就能使用线程池中的线程了。

总结

  • 池子一旦造出来后,固定了线程或进程。
  • 线程不会再变更,所有的任务都是这些线程处理。 这些线程不会再出现重复创建和销毁的过程。
  • 任务的提交是异步的,异步提交任务的返回结果,应该通过回调机制来获取。
  • 回调机制就相当于,把任务交给一个员工完成,它完成后主动找你汇报完成结果。

6. 查看线程数量

查看线程数量是通过 threading.enumerate() 方法来查看的。

import threading
import time

def test1():
    for i in range(5):
        print("--test1--%d"%i)
        time.sleep(1)

def test2():
    for i in range(5):
        print("--test2--%d"%i)
        time.sleep(1)


def main():
    t1 = threading.Thread(target=test1, name="t1")
    t2 = threading.Thread(target=test2)

    t1.start()
    t2.start()
    # 获取当前程序所有的线程
    print(threading.enumerate())

if __name__ == "__main__":
    main()

输出结果:

--test1--0
--test2--0
[<_MainThread(MainThread, started 140076707002112)>, <Thread(t1, started 140076670510848)>, <Thread(Thread-1, started 140076662118144)>]
--test1--1
--test2--1
--test1--2
--test2--2
--test2--3
--test1--3
--test2--4
--test1--4

如果多次运行,会发现打印的顺序并不是一致的。因为线程的运行时没有先后顺序的,谁先抢到资源就先执行谁。

7. 线程其它方法

import os
import threading
from threading import active_count, current_thread
import time


def task():
    print("hello")
    print(os.getpid())
    print(current_thread().name)
    time.sleep(1)


if __name__ == '__main__':
    t1 = threading.Thread(target=task, name="t1")
    t2 = threading.Thread(target=task, name="t2")
    t1.start()
    t1.join()    # 等待线程执行结果后,主线程继续执行
    t2.start()

    print(os.getpid())                  # 进程 ID
    print(current_thread().name)        # 获取线程名字
    print(active_count())       # 统计当前正在活跃的线程数量
  • join() :等待线程执行结果后,主线程继续执行
  • os.getpid() :进程 ID
  • current_thread().name :获取线程名字
  • active_count() :统计当前正在活跃的线程数量

8. 多个线程同时修改全局变量

import threading
import time

num = 0


def test1(nums):
    global num
    for i in range(nums):
        num += 1
    print("test1----num=%d" % num)


def test2(nums):
    global num
    for i in range(nums):
        num += 1
    print("test2----num=%d" % num)


def main():
    t1 = threading.Thread(target=test1, args=(1000000,))
    t2 = threading.Thread(target=test2, args=(1000000,))

    t1.start()
    t2.start()

    time.sleep(5)
    print("main-----num=%d" % num)


if __name__ == "__main__":
    main()

输出结果如下,当参数 args 变小时不会出现下面这种问题。

test1----num=1177810
test2----num=1476426
main-----num=1476426

当我们的线程 1 到 CPU 中执行代码 num+=1 的时候,其实这一句代码要被拆分为 3 个步骤来执行:

  • 第一步:获取 num 的值
  • 第二步:把获取的值 +1 操作
  • 第三步:把第二步获取的值存储到 num 中

我们在 CPU 中执行这三步的时候,并不能保证这三部一定会执行结束,再去执行线程 2 中的代码。
因为这是多线程的,所以 CPU 在处理两个线程的时候,是采用雨露均沾的方式,可能在线程一刚刚将 num+1 还没来得及将新值赋给 num 时,就开始处理线程二了,因此当线程二执行完全部的 num+=1 的操作后,可能又会开始对线程一的未完成的操作,而此时的操作停留在了完成运算未赋值的那一步,因此在完成对 num 的赋值后,就会覆盖掉之前线程二对 num+1 操作。

那我们应该怎么解决这个问题?这就要用到我们接下来的知识——锁。

9. 互斥锁

当多个线程几乎同时修改某一个共享数据的时候,需要进行同步控制。线程同步能够保证多个线程安全访问竞争资源,最简单的同步机制是引入互斥锁。

互斥锁为资源引入一个状态——锁定/非锁定。

某个线程要更改共享数据时,先将其锁定,此时资源的状态为“锁定”,其他线程不能改变,直到该线程释放资源,将资源的状态变成“非锁定”,其他的线程才能再次锁定该资源。互斥锁保证了每次只有一个线程进行写入操作,从而保证了多线程情况下数据的正确性。

基本使用:

import threading

# 生成锁对象,全局唯一
lock = threading.Lock()

# 获取锁。未获取到会阻塞程序,直到获取到锁才会往下执行
lock.acquire()

# 释放锁,归还锁,其他人可以拿去用了
lock.release()

需要注意的是,lock.acquire()lock.release() 必须成对出现。否则就有可能造成死锁。很多时候,我们虽然知道,他们必须成对出现,但是还是难免会有忘记的时候。

为了,规避这个问题。我推荐使用使用上下文管理器来加锁。

import threading

lock = threading.Lock()
with lock:
    # 这里写自己的代码
    pass

with 语句会在这个代码块执行前自动获取锁,在执行结束后自动释放锁。

互斥锁解决资源竞争

import threading
import time

num = 0
# 创建一个互斥锁,默认是没有上锁的
mutex = threading.Lock()

def test1(nums):
    global num
    mutex.acquire()
    for i in range(nums):
        num += 1
    mutex.release()
    print("test1----num=%d"%num)

def test2(nums):
    global num
    mutex.acquire()
    for i in range(nums):
        num += 1
    mutex.release()
    print("test1----num=%d" % num)

def main():
    t1 = threading.Thread(target=test1,args=(1000000,))
    t2 = threading.Thread(target=test2,args=(1000000,))

    t1.start()
    t2.start()
    time.sleep(2)
    print("main-----num=%d" % num)

if __name__ == "__main__":
    main()

此时输出的结果是没有问题的。互斥锁也会引发一个问题,就是死锁。

10. 死锁

当多个线程几乎同一 时间的去修改某个共享数据的时候就需要我们进行同步控制,线程同步能够保证多个线程安全的访问竞争资源,我们最简单的就是引入互斥锁 Lock、递归锁 RLock。这两种类型的锁有一点细微的区别,

像下面这种情况,就容易出现死锁。互相锁住了对方,又在等对方释放资源。

import threading  
 #Lock对象  
lock = threading.Lock()
#A 线程
lock.acquire(a)
lock.acquire(b)

#B 线程
lock.acquire(b)
lock.acquire(a)

当线程调用 lock 对象的 acquire() 方法时,lock 就会进入锁住状态,如果此时另一个线程想要获得这个锁,该线程就会变为阻塞状态,因为每次只能有一个线程能够获得锁,直到拥有锁的线程调用 lockrelease() 方法释放锁之后,线程调度程序从处于同步阻塞状态的线程中选择一个来获得锁,并使得该线程进入运行状态。

这种情况比较容易被发现,还有一种情况不太容易被发现,调用其他加锁函数,也可能造成死锁。

def add(lock):
    global total
    for i in range(100000):
        lock.acquire()
        task()
        total += 1
        lock.release()

def task():
    lock.acquire()
    # do something
    lock.release()

避免死锁:

  • 程序设计上尽量避免
  • 添加超时时间
import threading  
 #RLock对象  
rLock = threading.RLock() 
rLock.acquire()  
#在同一线程内,程序不会堵塞。
rLock.acquire()  
rLock.release()  
rLock.release()

RLock 允许在同一线程中被多次 acquire ,如果出现 Rlock ,那么 acquirerelease 必须成对出现,即调用了 iacquire ,必须调用 i 次的 release 才能真正释放所占用的锁。

需要注意的是,可重入锁( RLock ),只在同一线程里放松对锁(通行证)的获取,意思是,只要在同一线程里,程序就当你是同一个人,这个锁就可以复用,其他的话与 Lock 并无区别。

11. 线程同步

11.1 condition 条件变量

condition (条件变量):condition 有两把锁,一把底层锁会在线程底层调用 wait 后释放。我们每次调用 wait 时候回分配一把锁放到 condition 的等待队列中等待 notify 方法的唤醒。

import  threading
class factory(threading.Thread):

    def __init__(self,cond):
        super(factory,self).__init__(name="口罩生产厂家")
        self.cond = cond

    def run(self):
        with self.cond:
            self.cond.wait()
            print("{}:生产了10万个口罩,快来拿".format(self.name))
            self.cond.notify()

            self.cond.wait()
            print("{}:又生产了100万个口罩发往武汉".format(self.name))
            self.cond.notify()

            self.cond.wait()
            print("{}:加油,武汉!".format(self.name))
            self.cond.notify()

class wuhan(threading.Thread):
    def __init__(self,cond):
        super(wuhan,self).__init__(name="武汉志愿者")
        self.cond = cond

    def run(self):
        with self.cond:

            print("{}:能帮我们生产一批口罩吗?".format(self.name))
            self.cond.notify()
            self.cond.wait()

            print("{}:谢谢你们".format(self.name))
            self.cond.notify()
            self.cond.wait()

            print("{}:一起加油".format(self.name))
            self.cond.notify()
            self.cond.wait()

if __name__=="__main__":
    lock = threading.Condition()
    factory = factory(lock)
    wuhan = wuhan(lock)
    factory.start()
    wuhan.start()

上面的代码,大家看到我用到 with 语句,这是因为 Condition 源码中实现了 __enter____exit__,类中实现了这两个方法,就可以用 with 语句。而且 __enter__ 调用了 acquire() 方法,在 __exit__ 方法中调用了 release() 方法。

 def __enter__(self):
    return self._lock.__enter__()

def __exit__(self, *args):
    return self._lock.__exit__(*args)

11.2 semaphore 信号对象

semaphore (信号对象):用于控制进入数量的锁,Semaphore 对象管理着一个计数器,当我们每次调用 acquire() 方法的时候会进行递减,而每个 release() 方法调用递增,计数器永远不会低于零,当 acquire() 发现计数器为零的时候线程阻塞等待其他线程调用 release() ,具体如一下示例:

import threading
import time

class HtmlSpider(threading.Thread):
    def __init__(self, url, sem):
        super().__init__()
        self.url = url
        self.sem = sem

    def run(self):
        time.sleep(2)
        print("got html text success")
        self.sem.release()

class UrlProducer(threading.Thread):
    def __init__(self, sem):
        super().__init__()
        self.sem = sem

    def run(self):
        for i in range(20):
            self.sem.acquire()
            html_thread = HtmlSpider("https://baidu.com/{}".format(i), self.sem)
            html_thread.start()

if __name__ == "__main__":
    sem = threading.Semaphore(3)
    url_producer = UrlProducer(sem)
    url_producer.start()

12. 线程间通信

PythonQueue 模块中提供了以下几种队列类:

  • FIFO(先入先出) 队列 Queue
  • LIFO(后入先出)队列 LifoQueue
  • 优先级队列 Priority Queue

一般我们可以使用队列来实现线程同步,在开发中 FIFO 队列我们使用的比较多,下面我将用一个例子说明:

from threading import Thread
from time import sleep
from queue import Queue

#生产者
def Producer():
    count =0
    while True:
        if queue.qsize()<1000:
            for i in range(100):
                count +=1
                msg = "生产商品"+str(count)
                queue.put(msg)
                print(msg)

        sleep(0.5)

#消费者
def Consumer():
    while True:
        if queue.qsize()>100:
            for i in range(3):
                msg = "消费者消费了"+queue.get()
                print(msg)

        sleep(1)

if __name__=="__main__":
    #定义一个队列
    queue = Queue();

#初始化商品
for i in range(500):
    queue.put("初始商品"+str(i))
   #生产商品
    for i in range(4):
        p = Thread(target=Producer)
        p.start()
    #消费商品
    for i in range(10):
        c = Thread(target=Consumer)
        c.start()

队列对象(Queue、LifoQueue 或者 PriorityQueue)提供下列描述的公共方法。

  • Queue.qsize()
    返回队列的大致大小。注意,qsize()> 0 不保证后续的 get() 不被阻塞,qsize() < maxsize 也不保证 put() 不被阻塞。

  • Queue.empty()
    如果队列为空,返回 True,否则返回 False。如果 empty() 返回 True,不保证后续调用的 put() 不被阻塞。类似的,如果 empty() 返回 False,也不保证后续调用的 get() 不被阻塞。

  • Queue.full()
    如果队列是满的返回 True,否则返回 False。如果 full() 返回 True 不保证后续调用的 get() 不被阻塞。类似的,如果 full() 返回 False 也不保证后续调用的 put() 不被阻塞。

  • Queue.put(item, block=True, timeout=None)
    将 item 放入队列。如果可选参数 block 是 true 并且 timeout 是 None(默认),则在必要时阻塞至有空闲插槽可用。如果 timeout 是个正数,将最多阻塞 timeout 秒,如果在这段时间没有可用的空闲插槽,将引发 Full 异常。反之(block 是 false),如果空闲插槽立即可用,则把 item 放入队列,否则引发 Full 异常(在这种情况下,timeout 将被忽略)。

  • Queue.put_nowait (item)
    相当于 put(item, False)。

  • Queue.get(block=True, timeout=None)
    从队列中移除并返回一个项目。如果可选参数 block 是 true 并且 timeout 是 None(默认值),则在必要时阻塞至项目可得到。如果 timeout 是个正数,将最多阻塞 timeout 秒,如果在这段时间内项目不能得到,将引发 Empty 异常。反之(block 是 false),如果一个项目立即可得到,则返回一个项目,否则引发 Empty 异常(这种情况下,timeout 将被忽略)。

    POSIX 系统 3.0 之前,以及所有版本的 Windows 系统中,如果 block 是 true 并且 timeout 是 None,这个操作将进入基础锁的不间断等待。这意味着,没有异常能发生,尤其是 SIGINT 将不会触发 KeyboardInterrupt 异常。

  • Queue.get_nowait()
    相当于 get(False)。提供了两个方法,用于支持跟踪排队的任务是否被守护的消费者线程完整的处理。

  • Queue.task_done()
    表示前面排队的任务已经被完成。被队列的消费者线程使用。每个 get() 被用于获取一个任务,后续调用 task_done() 告诉队列,该任务的处理已经完成。

    如果 join() 当前正在阻塞,在所有条目都被处理后,将解除阻塞(意味着每个 put() 进队列的条目的 task_done() 都被收到)。 如果被调用的次数多于放入队列中的项目数量,将引发 ValueError 异常。

  • Queue.join()
    阻塞至队列中所有的元素都被接收和处理完毕。

    在多线程通信中,Queue 扮演者重要的角色,一般添加数据到队列使用 put() 方法,在队列中取数据使用 get() 方法,后面针对 Queue 还会做进一步的讲解

其它参考
https://segmentfault.com/a/1190000014306740
一篇带你熟练使用多线程与原理

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值