python_fullstack基础(十九)-并发编程-线程

并发编程

二、线程

1、理论背景

①线程的概念:

之前我们已经了解了进程,就是执行的程序就称之为进程,进程的出现使得用户感觉到自己在独享CPU,因此进程就是为了在CPU上实现多道编程而提出的;那么既然已经有了进程,那么为什么还会出现线程呢?进程可以让每个人都拥有自己的CPU和其他资源,可以提高计算机的利用率,但是它有两点主要缺陷:

  • 进程只能在同一时刻干一件事,如果在同一时刻同时处理多件事,进程就无能为力了
  • 进程在执行的过程中如果出现阻塞,整个进程就会挂起,即使进程中有些工作不依赖于输入的数据,也将无法继续执行所以,由于进程是资源拥有者,创建、撤消与切换存在较大的时空开销,需要引入轻型进程;二是由于对称多处理机(SMP)出现,可以满足多个运行单位,而多个进程并行开销过大。因此在80年代出现了能够独立运行的基本单位线程
  • ★进程是资源分配的最小单位,线程是CPU调度的最小单位;每一个进程中至少有一个线程。
②进程和线程的关系(区别)
  • 地址空间和其他资源(如打开文件):进程间相互独立,同一进程的各线程间共享。某进程内的线程在其它进程不可见。
  • 通信:线程间可以直接读写进程数据段(如全局变量)来进行通信;而进程间通信IPC,需要进程同步和互斥手段的辅助,以保证数据的一致性。
  • 调度和切换:线程上下文切换比进程上下文切换要快得多,因为不用重新加载地址空间等一系列资源
  • 在多线程操作系统中,进程不是一个可执行的实体
③线程的特点

在多线程操作系统中,通常是一个进程包括多个线程,每个线程都是作为利用CPU的基本单位,是花费最小开销的实体,线程具有以下特点:

  • 轻型实体
    线程中的实体基本上不拥有系统资源,只是有一点必不可少的、能保证独立运行的资源。
    线程的实体包括程序、数据和TCB。线程是动态概念,它的动态特性由线程控制块TCB(Thread Control Block)描述。

    • TCB包括以下信息:

    • (1)线程状态。
    • (2)当线程不运行时,被保存的现场资源。
    • (3)一组执行堆栈。
    • (4)存放每个线程的局部变量主存区。
    • (5)访问同一个进程中的主存和其它资源。
    • 用于指示被执行指令序列的程序计数器、保留局部变量、少数状态参数和返回地址等的一组寄存器和堆栈。
  • 独立调度和分派的基本单位
    在多线程操作系统中,线程是能独立运行的基本单位,因而也是独立调度和分派的基本单位。由于线程很“轻”,故在同一进程中的线程之间切换非常迅速且开销小

  • 共享进程资源
    在同一进程中的各个线程,都可以共享该进程所拥有的资源,这首先表现在:所有线程都具有相同的进程id,这意味着,线程可以访问该进程的每一个内存资源;此外,还可以访问进程所拥有的已打开文件、定时器、信号量机构等。由于同一个进程内的线程共享内存和文件,所以线程之间互相通信不必调用内核。

  • 可并发执行(CPython解释器中存在GIL而无法实现)
    在一个进程中的多个线程之间可以并发执行,甚至允许在一个进程中所有线程都能并发执行;同样,不同进程中的线程也能并发执行,充分利用和发挥了处理机与外围设备并行工作的能力。

  • ④线程使用场景

    这里写图片描述

    ⑤用户级线程&内核级线程(了解)
    • 线程的实现可以分为两类:
      用户级线程(User-Level Thread)和内核线线程(Kernel-Level Thread),后者又称为内核支持的线程或轻量级进程。在多线程操作系统中,各个系统的实现方式并不相同,在有的系统中实现了用户级线程,有的系统中实现了内核级线程。

    • 用户级线程(User-Level Thread)
      内核的切换由用户态程序自己控制内核切换,不需要内核干涉,少了进出内核态的消耗,但不能很好的利用多核CPU。在用户空间模拟操作系统对进程的调度,来调用一个进程中的线程,每个进程中都会有一个运行时系统,用来调度线程。此时当该进程获取cpu时,进程内再调度出一个线程去执行,同一时刻只有一个线程执行。

      • 优点:
        线程的调度不需要内核直接参与,控制简单。
        可以在不支持线程的操作系统中实现。
        创建和销毁线程、线程切换代价等线程管理的代价比内核线程少得多。
        允许每个进程定制自己的调度算法,线程管理比较灵活。
        线程能够利用的表空间和堆栈空间比内核级线程多。
        同一进程中只能同时有一个线程在运行,如果有一个线程使用了系统调用而阻塞,那么整个进程都会被挂起。另外,页面失效也会产生同样的问题。
      • 缺点:
        资源调度按照进程进行,多个处理机下,同一个进程中的线程只能在同一个处理机下分时复用
        这里写图片描述
    • 内核线线程(Kernel-Level Thread)
      切换由内核控制,当线程进行切换的时候,由用户态转化为内核态。切换完毕要从内核态返回用户态;可以很好的利用smp,即利用多核cpu。Windows系统中线程就是这样的。

      • 优点:当有多个处理机时,一个进程的多个线程可以同时执行。
      • 缺点:由内核进行调度。
        这里写图片描述
    • 混合实现
      用户级与内核级的多路复用,内核同一调度内核线程,每个内核线程对应n个用户线程
      这里写图片描述

    • 用户级与内核级线程的对比

      • 内核支持线程是OS内核可感知的,而用户级线程是OS内核不可感知的。
      • 用户级线程的创建、撤消和调度不需要OS内核的支持,是在语言(如Java)这一级处理的;而内核支持线程的创建、撤消和调度都需OS内核提供支持,而且与进程的创建、撤消和调度大体是相同的。
      • 用户级线程执行系统调用指令时将导致其所属进程被中断,而内核支持线程执行系统调用指令时,只导致该线程被中断。
      • 在只有用户级线程的系统内,CPU调度还是以进程为单位,处于运行状态的进程中的多个线程,由用户程序控制线程的轮换运行;在有内核支持线程的系统内,CPU调度则以线程为单位,由OS的线程调度程序负责线程的调度。
      • 用户级线程的程序实体是运行在用户态下的程序,而内核支持线程的程序实体则是可以运行在任何状态下的程序。

    2、python线程理论基础与threading.Thread模块

    ①理论基础
    • 全局解释器锁GIL
      Python代码的执行由Python虚拟机(也叫解释器主循环)来控制。Python在设计之初就考虑到要在主循环中,同时只有一个线程在执行。虽然 Python 解释器中可以“运行”多个线程,但在任意时刻只有一个线程在解释器中运行。对Python虚拟机的访问由全局解释器锁(GIL)来控制,正是这个锁能保证同一时刻只有一个线程在运行。

      • 在多线程环境中,Python 虚拟机按以下方式执行:

      a、设置 GIL;
      b、切换到一个线程去运行;
      c、运行指定数量的字节码指令或者线程主动让出控制(可以调用 time.sleep(0));
      d、把线程设置为睡眠状态;
      e、解锁 GIL;
      d、再次重复以上所有步骤。
      在调用外部代码(如 C/C++扩展函数)的时候,GIL将会被锁定,直到这个函数结束为止(由于在这期间没有Python的字节码被运行,所以不会做线程切换)编写扩展的程序员可以主动解锁GIL。

  • python线程模块的选择
    Python提供了几个用于多线程编程的模块,包括thread、threading和Queue等。thread和threading模块允许程序员创建和管理线程。thread模块提供了基本的线程和锁的支持,threading提供了更高级别、功能更强的线程管理的功能。Queue模块允许用户创建一个可以用于多个线程之间共享数据的队列数据结构。避免使用thread模块,因为更高级别的threading模块更为先进,对线程的支持更为完善,而且使用thread模块里的属性有可能会与threading出现冲突;其次低级别的thread模块的同步原语很少(实际上只有一个),而threading模块则有很多;再者,thread模块中当主线程结束时,所有的线程都会被强制结束掉,没有警告也不会有正常的清除工作,至少threading模块能确保重要的子线程退出后进程才退出。thread模块不支持守护线程,当主线程退出时,所有的子线程不论它们是否还在工作,都会被强行退出。而threading模块支持守护线程,守护线程一般是一个等待客户请求的服务器,如果没有客户提出请求它就在那等着,如果设定一个线程为守护线程,就表示这个线程是不重要的,在进程退出的时候,不用等待这个线程退出。

  • ②threading模块简介

    multiprocess模块的完全模仿了threading模块的接口,二者在使用层面,有很大的相似性,因而不再详细介绍
    https://docs.python.org/3/library/threading.html?highlight=threading#

    ③线程创建threading.Thread
    • Thread模块语法简介
      Process([group [, target [, name [, args [, kwargs]]]]])

      • 参数介绍

        group参数未使用,值始终为None
        target表示调用对象,即子进程要执行的任务
        args表示调用对象的位置参数元组,args=(1,2,’yang’,)
        kwargs表示调用对象的字典,kwargs={‘name’:’yang’,’age’:18}
        name为子进程的名称

      • Thread实例对象的方法

        isAlive(): 返回线程是否活动的
        getName(): 返回线程名
        setName(): 设置线程名

      • threading模块提供的一些方法:

        threading.currentThread(): 返回当前的线程变量
        threading.enumerate(): 返回一个包含正在运行的线程的list。正在运行指线程启动后、结束前,不包括启动前和终止后的线程
        threading.activeCount(): 返回正在运行的线程数量,与len(threading.enumerate())有相同的结果

    • 守护线程
      无论是进程还是线程,都遵循:守护xx会等待主xx运行完毕后被销毁。需要强调的是:运行完毕并非终止运行

      • 对主进程来说,运行完毕指的是主进程代码运行完毕
      • 对主线程来说,运行完毕指的是主线程所在的进程内所有非守护线程统统运行完毕,主线程才算运行完毕
      • 主进程在其代码结束后就已经算运行完毕了(守护进程在此时就被回收),然后主进程会一直等非守护的子进程都运行完毕后回收子进程的资源(否则会产生僵尸进程),才会结束,
      • 主线程在其他非守护线程运行完毕后才算运行完毕(守护线程在此时就被回收)。因为主线程的结束意味着进程的结束,进程整体的资源都将被回收,而进程必须保证非守护线程都运行完毕后才能结束。
      • 应用实例
    import time
    from threading import Thread
    
    def foo():
        print('thread-1...')
        time.sleep(3)
        print('守护线程thread-1...end...随着主线程Main thread结束而结束,所以这句话不会打印')
    
    def bar():
        print('thread-2...')
        time.sleep(1)
        print('thread-2...end...')
    
    t1 = Thread(target=foo)
    t2 = Thread(target=bar)
    
    t2.start()
    t1.daemon = True
    t1.start()
    
    print('Main thread...')
    • 实例化对象方式创建子线程
    import time
    import random
    from threading import Thread
    
    def hello(name):
        time.sleep(random.random())
        print('{} say hello'.format(name))
    
    if __name__ == '__main__':
        t = Thread(target=hello, args=('python',))
        t.start()
        print('Main Thread...')
    • 继承类方式创建子线程
    import time
    import random
    from threading import Thread
    
    class hello(Thread):
        def __init__(self, name):
            super().__init__()
            self.name = name
    
        def run(self):
            time.sleep(random.random())
            print('{} say hello'.format(self.name))
    
    if __name__ == '__main__':
        t = hello('Python')
        t.start()
        print('Main Thread...')
    • 多进程&多线程对比
    # 多进程与多线程——pid的比较
    from threading import Thread
    from multiprocessing import Process
    import os
    
    
    def work(name):
        print('{} 的进程ID:{}'.format(name, os.getpid()))
    
    
    if __name__ == '__main__':
        # part1:在主进程下开启多个线程,每个线程都跟主进程的pid一样
        t1 = Thread(target=work, args=('多线程',))
        t2 = Thread(target=work, args=('多线程',))
        t1.start()
        t2.start()
    
        # part2:开多个进程,每个进程都有不同的pid
        p1 = Process(target=work, args=('多进程',))
        p2 = Process(target=work, args=('多进程',))
        p1.start()
        p2.start()
    
    # 多进程与多线程——开启效率的比较
    from threading import Thread
    from multiprocessing import Process
    
    def work():
        print('hello')
    
    if __name__ == '__main__':
        # 在主进程下开启线程
        t = Thread(target=work)
        t.start()
        print('主线程/主进程')
        '''
        打印结果:
        hello
        主线程/主进程
        '''
    
        # 在主进程下开启子进程
        t = Process(target=work)
        t.start()
        print('主线程/主进程')
        '''
        打印结果:
        主线程/主进程
        hello
        '''
    
    # 内存数据的共享问题
    from  threading import Thread
    from multiprocessing import Process
    import os
    
    def work():
        global n
        n = 0
    
    if __name__ == '__main__':
        n = 100
        p = Process(target=work)
        p.start()
        p.join()
        print('Main...', n)  # 子进程p已经将自己的全局的n改成了0,但改的仅仅是它自己的,查看父进程的n仍然为100
    
        # n = 1
        # t = Thread(target=work)
        # t.start()
        # t.join()
        # print('Main...', n)  # 查看结果为0,因为同一进程内的线程之间共享进程内的数据,线程已经把值改成0了
    ④锁threading.Lock
    • 线程中锁的意义
      有些人认为CPython解释器由于存在GIL锁,所有在Cpython解释器下的线程锁就是没有必要的,这种说法是不正确的,因为GIL锁是针对线程的锁,而不是针对数据的锁,所以如果没有线程锁对数据进行加锁的话依然会有一定几率对数据的安全造成影响。

    • 应用举例—同步锁的引用

    '''
    改例是对于全局变量n进行减1操作,每开启一个线程就对n进行减1,正常情况下开启100个线程完成后,n减至0;但是
    如果在线程减1操作前后不对数据加锁的话,那么则会有可能出现,第一个线程拿走了n的初始值,正值此时CPU时间片
    轮转,下一个线程也拿走了n,此时n就被两个线程拿走,但是只会减1次,这样就出现了数据的不安全。
    '''
    '''
    有人可能有疑问:既然加锁会让运行变成串行,那么我在start之后立即使用join,就不用加锁了啊,也是串行的效果啊,
    是的,在start之后立刻使用join,肯定会将100个任务的执行变成串行,毫无疑问,最终n的结果也肯定是0,是安全的,但
    问题是start后立即join:任务内的所有代码都是串行执行的,而加锁,只是加锁的部分即修改共享数据的部分是串行的
    单从保证数据安全方面,二者都可以实现,但很明显是加锁的效率更高
    '''
    import time
    from threading import Thread
    from threading import Lock
    
    n = 100
    
    def func():
        global n
        time.sleep(1)
        lock.acquire()
        temp = n   # 从进程中获取n
        time.sleep(0.01)
        n = temp-1 # 得到结果,再存储回进程
        # print(n)
        lock.release()
    
    lock = Lock()
    t_lst = []
    for i in range(100):
        t = Thread(target=func)
        t.start()
        t_lst.append(t)
    [t.join() for t in t_lst]
    print('最终n的值:',n)
    • 死锁(进程、线程都会有死锁)

      • 定义:死锁: 是指两个或两个以上的进程或线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程

      • 应用实例—死锁的发生

    from threading import Lock as Lock
    mutexA=Lock()
    mutexA.acquire()
    mutexA.acquire()
    print('永远也不会执行到这句话...')
    mutexA.release()
    mutexA.release()
    • 递归锁(进程、线程都有递归锁)

      • 递归锁出现的意义:
        递归锁是为了解决死锁问题而出现的,在Python中为了支持在同一线程中多次请求同一资源,python提供了可重入锁RLock。这个RLock内部维护着一个Lock和一个counter变量,counter记录了acquire的次数,从而使得资源可以被多次require。直到一个线程所有的acquire都被release,其他的线程才能获得资源。上面的例子如果使用RLock代替Lock,则不会发生死锁

      • 应用实例——科学家吃面问题

    import time
    from threading import Thread,RLock
    fork_lock = noodle_lock = RLock()
    def eat1(name):
        noodle_lock.acquire()
        print('%s 抢到了面条'%name)
        fork_lock.acquire()
        print('%s 抢到了叉子'%name)
        print('%s 吃面'%name)
        fork_lock.release()
        noodle_lock.release()
    
    def eat2(name):
        fork_lock.acquire()
        print('%s 抢到了叉子' % name)
        time.sleep(1)
        noodle_lock.acquire()
        print('%s 抢到了面条' % name)
        print('%s 吃面' % name)
        noodle_lock.release()
        fork_lock.release()
    
    for name in ['科学家001','科学家002','科学家003']:
        t1 = Thread(target=eat1,args=(name,))
        t2 = Thread(target=eat2,args=(name,))
        t1.start()
        t2.start()
    ⑤信号量threading.Semaphore
    • 定义:同进程的一样,Semaphore管理一个内置的计数器,每当调用acquire()时内置计数器-1;调用release()时内置计数器+1;计数器不能小于0;当计数器为0时,acquire()将阻塞线程直到其他线程调用release()

    • 应用实例

    from threading import Thread
    from threading import Semaphore
    import threading
    import time
    def func():
        sm.acquire()
        print('%s get sm' %threading.current_thread().getName())
        time.sleep(3)
        sm.release()
    if __name__ == '__main__':
        sm=Semaphore(5)
        for i in range(23):
            t=Thread(target=func)
            t.start()
    • 池与信号量
      与进程池是完全不同的概念,进程池Pool(4),最大只能产生4个进程,而且从头到尾都只是这四个进程,不会产生新的,而信号量是产生一堆线程/进程
    ⑥事件threading.Event
    • 定义:
      同进程的一样,线程的一个关键特性是每个线程都是独立运行且状态不可预测。如果程序中的其 他线程需要通过判断某个线程的状态来确定自己下一步的操作,这时线程同步问题就会变得非常棘手。为了解决这些问题,我们需要使用threading库中的Event对象。对象包含一个可由线程设置的信号标志,它允许线程等待某些事件的发生。在初始情况下,Event对象中的信号标志被设置为假。如果有线程等待一个Event对象, 而这个Event对象的标志为假,那么这个线程将会被一直阻塞直至该标志为真。一个线程如果将一个Event对象的信号标志设置为真,它将唤醒所有等待这个Event对象的线程。如果一个线程等待一个已经被设置为真的Event对象,那么它将忽略这个事件, 继续执行。

    • Event对象的一些方法:

    event.isSet():返回event的状态值
    event.wait():如果 event.isSet()==False将阻塞线程
    event.set():设置event的状态值为True,所有阻塞池的线程激活进入就绪状态,等待操作系统调度
    event.clear():恢复event的状态值为False
    • 应用实例——模拟数据库连接
    import threading
    import time
    import random
    from threading import Thread
    from threading import Event
    
    def conn_mysql():
        count=1
        while not event.is_set():
            if count > 3:
                raise TimeoutError('链接超时')
            print('<%s>第%s次尝试链接' % (threading.current_thread().getName(), count))
            event.wait(0.5)
            count+=1
        print('<%s>链接成功' %threading.current_thread().getName())
    
    def check_mysql():
        print('\033[45m[%s]正在检查mysql\033[0m' % threading.current_thread().getName())
        time.sleep(random.randint(2,4))
        event.set()
    if __name__ == '__main__':
        event=Event()
        conn1=Thread(target=conn_mysql)
        conn2=Thread(target=conn_mysql)
        check=Thread(target=check_mysql)
    
        conn1.start()
        conn2.start()
        check.start()
    ⑦条件threading.Condition
    • 定义:使线程等待,只有满足某条件时,才释放n个线程
      Python提供的Condition对象提供了对复杂线程同步问题的支持。Condition被称为条件变量,除了提供与Lock类似的acquire和release方法外,还提供了wait和notify方法。线程首先acquire一个条件变量,然后判断一些条件。如果条件不满足则wait;如果条件满足,进行一些处理改变条件后,通过notify方法通知其他线程,其他处于wait状态的线程接到通知后会重新判断条件。不断的重复这一过程,从而解决复杂的同步问题。

    • 应用实例

    import threading
    
    def run(n):
        con.acquire()
        con.wait()
        print("run the thread: %s" % n)
        con.release()
    
    if __name__ == '__main__':
    
        con = threading.Condition()
        for i in range(10):
            t = threading.Thread(target=run, args=(i,))
            t.start()
    
        while True:
            inp = input('>>>')
            if inp == 'q':
                break
            con.acquire()
            con.notify(int(inp))
            con.release()
            print('****')
    ⑧定时器threading.Timer
    • 定义:指定n秒后执行某个操作

    • 应用实例

    from threading import Timer
    
    def hello():
       print("hello, world")
    
    t = Timer(1, hello)
    t.start()  # after 1 seconds, "hello, world" will be printed
    ⑨队列import queue
    • 定义:使用import queue,用法与进程Queue一样

    • class queue.Queue(maxsize=0) #先进先出

      • 应用实例
    import queue
    
    q=queue.Queue()
    q.put('first')
    q.put('second')
    q.put('third')
    
    print(q.get())
    print(q.get())
    print(q.get())
    
    结果(先进先出):
    >>> first
    >>> second
    >>> third
    • class queue.LifoQueue(maxsize=0) #后进先出

      • 应用实例
    import queue
    
    q=queue.LifoQueue()
    q.put('first')
    q.put('second')
    q.put('third')
    
    print(q.get())
    print(q.get())
    print(q.get())
    
    结果(后进先出):
    >>> third
    >>> second
    >>> first
    • class queue.PriorityQueue(maxsize=0) #存储数据时可设置优先级的队列

      • 应用实例
    import queue
    
    q=queue.PriorityQueue()
    #put进入一个元组,元组的第一个元素是优先级(通常是数字,也可以是非数字之间的比较),数字越小优先级越高
    q.put((20,'a'))
    q.put((10,'b'))
    q.put((30,'c'))
    
    print(q.get())
    print(q.get())
    
    结果(数字越小优先级越高,优先级高的优先出队):
    >>> (10, 'b')
    >>> (20, 'a')
    >>> (30, 'c')
    ⑩Python标准模块–concurrent.futures
    • 介绍:
      concurrent.futures模块提供了高度封装的异步调用接口
      ThreadPoolExecutor:线程池,提供异步调用
      ProcessPoolExecutor: 进程池,提供异步调用
      Both implement the same interface, which is defined by the abstract Executor class.

    • 基本方法

      • submit(fn, *args, **kwargs) 异步提交任务
      • map(func, *iterables, timeout=None, chunksize=1) 取代for循环submit的操作
      • shutdown(wait=True)

        • 相当于进程池的pool.close()+pool.join()操作
        • wait=True,等待池内所有任务执行完毕回收完资源后才继续
        • wait=False,立即返回,并不会等待池内的任务执行完毕
        • 但不管wait参数为何值,整个程序都会等到所有任务执行完毕
        • submit和map必须在shutdown之前
      • result(timeout=None) 取得结果

      • add_done_callback(fn) 回调函数
    • ProcessPoolExecutor(ThreadPoolExecutor类似)

    • map用法

    from concurrent.futures import ThreadPoolExecutor,ProcessPoolExecutor
    
    import os,time,random
    def task(n):
        print('%s is runing' %os.getpid())
        time.sleep(random.randint(1,3))
        return n**2
    
    if __name__ == '__main__':
    
        executor=ThreadPoolExecutor(max_workers=3)
        executor.map(task,range(1,12)) #map取代了for+submit
    • 回调函数
    from concurrent.futures import ThreadPoolExecutor,ProcessPoolExecutor
    from multiprocessing import Pool
    import requests
    import json
    import os
    
    def get_page(url):
        print('<进程%s> get %s' %(os.getpid(),url))
        respone=requests.get(url)
        if respone.status_code == 200:
            return {'url':url,'text':respone.text}
    
    def parse_page(res):
        res=res.result()
        print('<进程%s> parse %s' %(os.getpid(),res['url']))
        parse_res='url:<%s> size:[%s]\n' %(res['url'],len(res['text']))
        with open('db.txt','a') as f:
            f.write(parse_res)
    
    
    if __name__ == '__main__':
        urls=[
            'https://www.baidu.com',
            'https://www.python.org',
            'https://www.openstack.org',
            'https://help.github.com/',
            'http://www.sina.com.cn/'
        ]
    
        # p=Pool(3)
        # for url in urls:
        #     p.apply_async(get_page,args=(url,),callback=pasrse_page)
        # p.close()
        # p.join()
    
        p=ProcessPoolExecutor(3)
        for url in urls:
            #parse_page拿到的是一个future对象obj,需要用obj.result()拿到结果
            p.submit(get_page,url).add_done_callback(parse_page) 
  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值