• 【Python】 多线程并发threading & 任务队列Queue


    threading

      【这篇文章的阅读量越来越多了… 因此我觉得有必要声明下,文章的性质是我个人的学习记录和总结,并非教程,文中难免有表达不严谨,甚至错误的地方。如果您只是相对threading相关内容做个大概的了解,希望能对您有所参考。如果想要精密地学习,请移步正规教材、文档以及大牛的博客】

      python程序默认是单线程的,也就是说在前一句语句执行完之前后面的语句不能继续执行(不知道我理解得对不对)

      先感受一下线程,一般情况下:

    def testa():
        sleep(1)
        print "a"
    
    def testb():
        sleep(1)
        print "b"
    
    testa()
    testb()
    #先隔出一秒打印出a,再过一秒打出b

      但是如果用了threading的话:

    ta = threading.Thread(target=testa)
    tb = threading.Thread(target=testb)
    for t in [ta,tb]:
        t.start()
    for t in [ta,tb]:
        t.join()
    print "DONE"
    
    #输出是ab或者ba(紧贴着的)然后空一行再来DONE的结果。

       得到这样的结果是因为这样的,在start之后,ta首先开始跑,但是主线程(脚本本身)没有等其完成就继续开始下一轮循环,然后tb也开始了,在之后的一段时间里,ta和tb两条线程(分别代表了testa和testb这两个过程)共同执行。相对于一个个迭代而言,这样做无疑是大大提高了运行的速度。

      Thread类为线程的抽象类,其构造方法的参数target指向一个函数对象,即该线程的具体操作。此外还可以有args=<tuple>来给target函数传参数。需要注意的是当传任何一个序列进去的话Thread会自动把它分解成单个单个的元素然后分解传给target函数。我估计在定义的时候肯定是*args了。

      join方法是个很tricky的东西,至今还不是很清楚地懂这是个什么玩意儿。join([timeout])方法阻塞了主线程,直到调用此方法的子线程完成之后主线程才继续往下运行。(之前我糊里糊涂地把join就紧紧接在start后面写了,如果这么写了的话那么多线程在速度上就毫无优势,和单线程一样了= =)。而像上面这个示例一样,先一个遍历把所有线程 都启动起来,再用一个遍历把所有线程都join一遍似乎是比较通行的做法。

      ●  关于线程锁

      多线程程序涉及到一个问题,那就是当不同线程要对同一个资源进行修改或利用时会出现混乱,所以有必要引入线程锁。

      (经网友提醒,补充一下相关例子)比如下面这一段程序:

    import threading
    
    class MyThread(threading.Thread):
        def __init__(self,counter,name):
            self.counter = counter
         self.name = name
    
        def run(self):
            self.counter[0] += 1
            print self.counter[0]
    
    if __name__ == '__main__':
        counter = [0]
        for i in range(1,11):
            t = MyThread(counter,i)
            t.start()

      这里并发了10个线程,在没有混乱的情况下,很明显一个线程的name和经过它处理过后的counter中的数字应该相同。因为没有锁可能引发混乱,想象中,我们可能认为,当某个线程要打印counter中的数字时,别的线程对其作出了改变,从而导致打印出的counter中的数字不符合预期。实际上,这段代码的运行结果很大概率是很整齐的1 2 3....10。如果要解释一下,1. 虽然称并发10个线程。但是实际上线程是不可能真的在同一个时间点开始,比如在这个例子中t1启动后,要将循环进入下一轮,创建新的线程对象t2,然后再让t2启动。这段时间虽然很短很短,但是确实是存在的。而这段时间的长度,足够让t1的run中,进行自增并且打印的操作。最终,整个结果看上去似乎没什么毛病。

      如果我们想要看到“混乱”的情况,显然两个方法。要么缩短for i in range以及创建线程对象的时间,使得线程在自增之后来不及打印时counter被第二个线程自增,这个比较困难;另一个方法就是延长自增后到打印前的这段时间。自然想到,最简单的,用time.sleep(1)睡一秒即可。此时结果可能是10 10 ...。主要看第一行的结果。不再是1而是10了。说明在自增操作结束,打印数字之前睡的这一秒里,到第10个线程都成功自增了counter,因此即使是第一个线程,打印到的也是经过第10个线程修改的counter了。

      上述结果虽然数的值上改变了,但是很可能输出仍然是整齐的一行行的。有时候好几个数字会挤在一起输出,尤其是把并发量调大,比如调到100或1000,尤为明显。挤在一起主要是因为,time.sleep(1)并不是精精确确地睡1秒。有可能是0.999或者1.001(具体差异可能更小,打个比方)。此时可能tk线程睡了1.001秒而tk+1线程睡了0.999秒,导致两者打印时内容被杂乱地一起写入缓冲区,所以打印出来的就凌乱了。根据时间误差的不同,甚至有可能出现大数字先打印出来的情况。

      可以通过Thread.Lock类来创建简单的线程锁。lock = threading.Lock()即可。在某线程start中,处理会被争抢的资源之前,让lock.acquire(),且lock在acquire()之后不能再acquire,否则会报错。当线程结束处理后调用lock.release()来释放锁就好了。一般而言,有锁的多线程场景可以提升一部分效率,但在写文件等时机下会有阻塞等待的情况。

      为了说明简单的lock,我们改一下上面那段程序:

    import threading
    import time
    
    class MyThread(threading.Thread):
        def __init__(self,lock,name):
            threading.Thread.__init__(self)
            self.lock = lock
            self.name = name
    
        def run(self):
            time.sleep(1)
            # self.lock.acquire()
            print self.name
            # self.lock.release()
    
    if __name__ == '__main__':
        lock = threading.Lock()
        for i in range(1,10):
            t = MyThread(lock,i)
            t.start()

      根据启动的顺序,每个线程有了name属性。然后启动,在没有锁的情况下可能会出现挤在一起,并且数字乱序输出的情况。把两句注释去掉,加上锁之后,得到的输出肯定是一行一行的,但是数字仍然有可能是乱序的。分析一下,加上锁之后,每次进行print,其实是线程对于sys.stdout写入内容,有多个线程都要print就形成了竞争,因此就会导致挤在一起。加上锁,acquire之后,本线程拥有了对sys.stdout的独享,因此可以正确输出内容+换行,再解开锁供下一个需要打印的线程使用。那为什么乱序问题还是没有解决呢?这个就是(推测)因为前面提到的time.sleep的不精确性。有可能6号线程sleep了稍微久而7号稍微短了些,导致7号先于6号获得锁。自然7就比6先打印出来了。如果稍微有意思地改动一下,比如sleep的秒数时间错开来,1号线程睡1秒,2号线程睡2秒这样子的话,时间上的错开使得没有了对资源的竞争的情况,因此即使没有锁也不会乱。

      总结一下,1. 对于run过程中对于可能有竞争的资源之前所做的操作,花费时间越是接近,越有可能发生资源竞争从而导致混乱。(废话…)2. 当run中有print或者类似操作时需要注意,其实隐含着要对stdout做出竞争的意义

      

      相比之下,无所多线程场景可以进一步提升效率,但是可能会引起读写冲突等问题,所以要慎用。一定要确认各个线程间没有共同的资源之类的问题后再实行无锁多线程。

      和Lock类类似的还有一个RLock类,与Lock类的区别在于RLock类锁可以嵌套地acquire和release。也就是说在同一个线程中acquire之后再acquire也不会报错,而是将锁的层级加深一层。只有当每一层锁从下到上依次都release开这个锁才算是被解开。

      ●  更加强大的锁——Condition

      上面提到的threading.Lock类提供了最为简单的线程锁的功能。除了Lock和RLock以外,其实threading还补充了其他一些很多的带有锁功能的类。Condition就是其中最为强大的类之一。

      在说Condition之前还需要明确一下线程的几个概念。线程的阻塞和挂起,线程的这两个状态乍一看都是线程暂停不再继续往前运行,但是引起的原因不太一样。阻塞是指线程间互相的制约,当一个线程获得了锁,其他的线程就被阻塞了,而挂起是出于统一调度的考虑。换句话说,挂起是一种主动的行为,在程序中我们主动挂起某个线程然后可以主动放下让线程继续运行;而阻塞更多时候是被动发生的,当有线程操作冲突了那么必然是有一方要被阻塞的。从层级上看,挂起操作是高于阻塞的,也就说一个线程可以在阻塞的时候被挂起,然后被唤醒后依然是阻塞状态。如果在挂起过程中具备了运行条件(即不再阻塞),线程也不会往前运行。

      再来看看Condition类的一些方法。首先是acquire和release,Condition内部也维护了一把锁,默认是RLock类,所有关联了同一个Condition对象的线程也都会遵守这把锁规定的来进行运行。

      Condition.wait([timeout])  这个方法一定要在获取锁定之后调用,调用这个方法的Condition对象所在的线程会被挂起并且释放这个线程获得着的所有锁,直到接到通知被唤醒或者超时(如果设置了Timeout的话),当被唤醒之后线程将重新获取锁定。

      Condition.notify()  notify就是上面所说的通知,调用这个方法之后会唤醒一个被挂起的线程。线程的选择尚不明确,似乎是随机的。需要注意的是notify方法只进行挂起的唤醒而不涉及锁的释放

      Condition.notify_all()  唤醒所有挂起的线程

      基于上面这几个方法,就可以做出比较好的线程管理的demo了,比如下面这段网上常见的一个捉迷藏的模拟程序:

    import threading,time
    
    class Seeker(threading.Thread):
        def __init__(self,cond,name):
            Thread.__init__(self)
            self.cond = cond
            self.name = name
    
        def run(self):
            time.sleep(1)    #1.确保seeker晚于hider开始执行
    
            self.cond.acquire()    #4. hider的锁释放了所以这里获得了锁
            print '我把眼睛蒙上了'
            self.cond.notify()    #5.蒙上眼后通知hider,hider线程此时被唤醒并试图获取锁,但是锁还在seeker身上,所以hider被阻塞,seeker继续往下
            self.cond.wait()  #6. seeker锁被释放并且挂起,hider就获取锁开始继续往下运行了
            
            print '我找到你了'
            self.cond.notify()  #9.找到了之后通知hider,hider意图获取锁但不行所以被阻塞,seeker往下
            self.cond.release()  #10.释放锁
    
            print '我赢了'
    
    class Hider(threading.Thread):
            def __init__(self,cond,name):
                Thread.__init__(self)
                self.cond = cond
                self.name = name
            
            def run(self):
                self.cond.acquire()    #2.hider获取锁
                self.cond.wait()    #3.hider被挂起然后释放锁
                
                print '我已经藏好了'
                self.cond.notify()  #7.藏好后通知seeker,seeker意图获取锁,但是锁在hider身上所以seeker被阻塞
                self.cond.wait()    #8.hider被挂起,释放锁,seeker获取锁,seeker继续往下运行
    
                self.cond.release()  #11. 在此句之前一点,seeker释放了锁(#10),hider得到锁,随即这句hider释放锁
                print '被你找到了'
    
    cond = threading.Condition()
    seeker = Seeker(cond,'seeker')
    hider = Hider(cond,'hider')
    seeker.start()
    hider.start()
    
    '''
    结果:
    我把眼睛蒙上了
    我已经藏好了
    我找到你了
    我赢了
    被你找到了
    '''

      这里需要注意的是self.cond.release方法不能省,否则会引起死锁。

      

      ●  以上的包装线程的方式是一种面向过程的方法,下面介绍一下如何面向对象地来抽象线程

      面向对象地抽象线程需要自定义一个类继承Thread类。比如自定义class MyThread(Thread)。这个类的一个实例就是代表了一个线程,然后通过重载这个类中的run方法(是run,不是start!!但start的动作确实就是调用run)来执行具体的操作。此时锁可以作为一个构造方法的参数,将一个锁传进不同的实例中以实现线程锁控制。比如:

      引用自http://www.cnblogs.com/tkqasn/p/5700281.html

    #方法二:从Thread继承,并重写run()
    class MyThread(threading.Thread):
        def __init__(self,arg):
            super(MyThread, self).__init__()#注意:一定要显式的调用父类的初始化函数。
            self.arg=arg
        def run(self):#定义每个线程要运行的函数
            time.sleep(1)
            print 'the arg is:%s
    ' % self.arg
    
    for i in xrange(4):
        t =MyThread(i)
        t.start()
    
    print 'main thread end!'

      

      Thread类还有以下的一些方法,自定义的类也可以调用

        getName()

        setName(...)  //其实Thread类在构造方法中有一个name参数,可以为相应的线程取一个名字。这两个方法就是相关这个名字属性的

        isAlive()  一个线程从start()开始到run()结束的过程中没有异常,则其实alive的。

        setDaemon(True/False)  是否设置一个线程为守护线程。当你设置一个线程为守护线程之后,程序不会等待这个线程结束再退出程序,可参考http://blog.csdn.net/u012063703/article/details/51601579

      ●  除了Thread类,threading中还有以下一些属性,简单介绍一下:

        Timer类,Timer(int,target=func)  和Thread类类似,只不过它在int秒过后才以target指定的函数开始线程运行

        currentThread()  获得当前线程对象

        activeCount()  获得当前活动的线程总个数

        enumerate()  获得所有活动线程的列表

        settrace(func)  设置一跟踪函数,在run执行前执行

        setprofile(func)  设置一跟踪函数,在run执行完毕之后执行

      以上内容是目前我所能驾驭的,而threading类还有很多很NB的东西比如RLock类,Condition类,Event类等等。没什么时间再仔细研究它们,先写到这里为止。

    Queue

      Queue用于建立和操作队列,常和threading类一起用来建立一个简单的线程队列。

      首先,队列有很多种,根据进出顺序来分类,可以分成

        Queue.Queue(maxsize)  FIFO(先进先出队列)

        Queue.LifoQueue(maxsize)  LIFO(先进后出队列)

        Queue.PriorityQueue(maxsize)  为优先级越高的越先出来,对于一个队列中的所有元素组成的entries,优先队列优先返回的一个元素是sorted(list(entries))[0]。至于对于一般的数据,优先队列取什么东西作为优先度要素进行判断,官方文档给出的建议是一个tuple如(priority, data),取priority作为优先度。

        如果设置的maxsize小于1,则表示队列的长度无限长

      FIFO是常用的队列,其一些常用的方法有:

        Queue.qsize()  返回队列大小

        Queue.empty()  判断队列是否为空

        Queue.full()  判断队列是否满了

        Queue.get([block[,timeout]])  从队列头删除并返回一个item,block默认为True,表示当队列为空却去get的时候会阻塞线程,等待直到有有item出现为止来get出这个item。如果是False的话表明当队列为空你却去get的时候,会引发异常。在block为True的情况下可以再设置timeout参数。表示当队列为空,get阻塞timeout指定的秒数之后还没有get到的话就引发Full异常。

        Queue.put(...[,block[,timeout]])  向队尾插入一个item,同样若block=True的话队列满时就阻塞等待有空位出来再put,block=False时引发异常。同get的timeout,put的timeout是在block为True的时候进行超时设置的参数。

        Queue.task_done()  从场景上来说,处理完一个get出来的item之后,调用task_done将向队列发出一个信号,表示本任务已经完成

        Queue.join()  监视所有item并阻塞主线程,直到所有item都调用了task_done之后主线程才继续向下执行。这么做的好处在于,假如一个线程开始处理最后一个任务,它从任务队列中拿走最后一个任务,此时任务队列就空了但最后那个线程还没处理完。当调用了join之后,主线程就不会因为队列空了而擅自结束,而是等待最后那个线程处理完成了。

      结合threading和Queue可以构建出一个简单的生产者-消费者模型,比如:

      下面的代码引用自http://blog.csdn.net/l1902090/article/details/24804085

        import threading  
        import Queue  
        import time  
        class worker(threading.Thread):  
         def __init__(self,queue):  
          threading.Thread.__init__(self)  
          self.queue=queue  
          self.thread_stop=False  
           
         def run(self):  
          while not self.thread_stop:  
           print("thread%d %s: waiting for tast" %(self.ident,self.name))  
           try:  
            task=q.get(block=True, timeout=20)#接收消息  
           except Queue.Empty:  
            print("Nothing to do!i will go home!")  
            self.thread_stop=True  
            break  
           print("task recv:%s ,task No:%d" % (task[0],task[1]))  
           print("i am working")  
           time.sleep(3)  
           print("work finished!")  
           q.task_done()#完成一个任务  
           res=q.qsize()#判断消息队列大小  
           if res>0:  
            print("fuck!There are still %d tasks to do" % (res))  
           
         def stop(self):  
          self.thread_stop = True  
           
        if __name__ == "__main__":  
         q=Queue.Queue(3)  
         worker=worker(q)  
         worker.start()  
         q.put(["produce one cup!",1], block=True, timeout=None)#产生任务消息  
         q.put(["produce one desk!",2], block=True, timeout=None)  
         q.put(["produce one apple!",3], block=True, timeout=None)  
         q.put(["produce one banana!",4], block=True, timeout=None)  
         q.put(["produce one bag!",5], block=True, timeout=None)  
         print("***************leader:wait for finish!")  
         q.join()#等待所有任务完成  
         print("***************leader:all task finished!")  

      (嗯。。姑且不论他的F-word哈哈哈,开玩笑的,这例子还可以,至少很清晰地说明了如何把这两个模块结合起来用)

      输出是这样的:

        thread139958685849344 Thread-1: waiting for tast 1
        task recv:produce one cup! ,task No:1
        i am working
        work finished!
        fuck!There are still 3 tasks to do
        thread139958685849344 Thread-1: waiting for tast 1
        task recv:produce one desk! ,task No:2
        i am workingleader:wait for finish!
        work finished!
        fuck!There are still 3 tasks to do
        thread139958685849344 Thread-1: waiting for tast 1
        task recv:produce one apple! ,task No:3
        i am working
        work finished!
        fuck!There are still 2 tasks to do
        thread139958685849344 Thread-1: waiting for tast 1
        task recv:produce one banana! ,task No:4
        i am working
        work finished!
        fuck!There are still 1 tasks to do
        thread139958685849344 Thread-1: waiting for tast 1
        task recv:produce one bag! ,task No:5
        i am working
        work finished!
        thread139958685849344 Thread-1: waiting for tast 1
         ***************leader:all task finished!
        Nothing to do!i will go home!

       运行一下就知道,上例中并没有性能的提升(毕竟还是只有一个线程在跑)。线程队列的意义并不是进一步提高运行效率,而是使线程的并发更加有组织。可以看到,在增加了线程队列之后,程序对于线程的并发数量就有了控制。新线程想要加入队列开始执行,必须等一个既存的线程完成之后才可以。举个例子,比如

    for i in range(x):
      t = MyThread(queue)
      t.start()

      x在这里是个变量,我们不知道这个循环会触发多少线程并发,如果多的话就会很冒险。但是有了队列之后,把一个队列作为所有线程构建线程对象时的一个参数,让线程必须按照这个队列规定的大小来执行的话,就不担心过多线程带来的危险了。

    ■  线程池实现

      不得不说一年前还是太simple。。 一年后再来补充点内容吧

      首先我们要明确,线程池,线程,队列这几个概念之间的区别和联系。

      举一个不太恰当的例子。比如有五个很饿的人去吃旋转寿司。旋转寿司店里有一个传送带,将寿司运送到他们面前。他们一字排开坐好准备好吃,当寿司过来,食客可能会选择一个喜欢的口味开吃。在吃的过程中,他通常就不会再去“吃着碗里看着传送带上的”了。之所以是很饿的人,因为我们假定他们一旦吃完一盘就会立刻着手下一盘,毫不停歇。

      在这个场景中,五个人组成的集体是线程池,每个人就是一个线程,而旋转寿司的传送带是队列,每盘寿司就是一个队列中的任务。之所以说这个例子不太恰当,是因为场景中食客可以自己选择想吃的寿司而线程池-队列中,队列才是任务分配的主导。就好比是传送带发现某个食客说他已经吃完一盘寿司,还想再来一盘的时候,会不顾食客的喜好,强行将一盘寿司推到一个空闲的食客面前让他吃。

      更加抽象点来说,线程在这个语境中其实就像是一个工具,而线程池就是一个工具的集合。由于通常一个线程池面向的是一类任务,所以线程池中的线程基本上也是同质的。即上述的五个食客是五胞胎(误hh)。另一方面,之所以说面向的是一类任务,是因为队列中的任务通常是具有某些共性的。共性程度高低取决于队列以及线程池的具体实现,但是肯定是有的。这就好比寿司可以有握り,巻き而上面的具可以有いくら、マグロ、ウニ但是归根结底肯定还是要有米饭的。

      在正式的开发中,队列通常是由第三方服务提供比如RabbitMQ,Redis等。而线程池通常由程序自己实现。下面这段代码则是在一个python程序中,基于Queue加上自制的建议线程池建立起来的模型。

    # -*- coding:utf-8 -*-
    
    import threading
    import Queue
    import time
    import random
    
    from faker import Faker
    
    class MyThread(threading.Thread):
      '''
      线程模型
      '''
      def __init__(self,queue):
        threading.Thread.__init__(self)
        self.queue = queue
        self.start()  # 因为作为一个工具,线程必须永远“在线”,所以不如让它在创建完成后直接运行,省得我们手动再去start它
    
        def run(self):
            while True:  # 除非确认队列中已经无任务,否则时刻保持线程在运行
                try:
                    task = self.queue.get(block=False)    # 如果队列空了,直接结束线程。根据具体场景不同可能不合理,可以修改
                    time.sleep(random.random())  # 假设处理了一段时间
                    print 'Task %s Done' % task  # 提示信息而已
                    self.queue.task_done()
                except Exception,e:
                    break
    
    class MyThreadPool():
        def __init__(self,queue,size):
            self.queue = queue
            self.pool = []
            for i in range(size):
                self.pool.append(MyThread(queue))
    
        def joinAll(self):
            for thd in self.pool:
                if thd.isAlive():  thd.join()
    
    if __name__ == '__main__':
        q = Queue.Queue(10)
        fake = Faker()
        for i in range(5):
            q.put(fake.word())
        pool = MyThreadPool(queue=q,size=2)
        pool.joinAll()

      网上有一部分示例,将队列作为一个属性维护在了线程池类中,也不失为一种办法,我这里为了能够条理清晰,没有放在类里面。这段程序首先生成了一个maxsize是10的队列。fake.word()可以随机生成一个单词,这里仅作测试用。所以向队列中添加了5个task。

      这里有个坑: 如果put的数量大于队列最大长度,而且put没有设置block=False的话,那么显然程序会阻塞在put这边。此时ThreadPool未被建立,也就是说工作线程都还没有启动,因此会引起这样一个死锁。如果把线程池的建立放到put之前也不行,此时线程发现队列为空,所以所有线程都会直接结束(当然这是线程中get的block是False的时候,如果为True那么也是死锁),最终队列中的task没人处理,程序输出为空。解决这个坑的办法,一个是像上面一样保持最开始put的量小于队列长度;第二个就是干脆不要限制队列长度,用q = Queue.Queue()生产队列即可。

      好的,继续往下,进入了线程池的生成。线程池内部的列表才是真·线程池,另外其关联了queue对象,所以在创建的时候可以将队列对象传递给线程对象。线程对象在创建时就启动了,并且被添加到线程池的那个列表中。线程池的大小由参数给出,线程启动后会去队列里面get任务,并且进行处理。处理完成后进行task_done声明并且再次去尝试get。如果队列为空那么就直接抛出异常,也就是跳出循环,线程结束。

      通过这样一个模型,根据线程池的大小,这才真正地给线程并发做了一个限制,可促进较大程度的资源利用。

      ●  进一步地…

      在上面这个示例中,实际上处理任务的实际逻辑是被写在了MyThread类里面。如果我们想要一个通用性更加高的工具类,那么势必要想想如何将这个线程类解耦具体逻辑。另一方面,队列中的任务的内容,不仅仅可以是字符串,也可以是任何python对象。这就使得灵活性大大提高。

      比如我们可以在队列中put内容是(func, args, kwargs)这样一个元组。其中func是一个函数对象,描述了任务的处理逻辑过程,args是一个元组,代表所有func函数的匿名参数,kwargs则是func函数的所有具名参数。如此,可以将线程类的run方法改写成这样:

    def run(self):
        while True:
            try:
                func,args,kwargs = self.queue.get()
                try:
                    func(*args,**kwargs)
                except Exception,e:
                    raise ('bad execution: %s' % str(e))
                self.queue.task_done()
            except Exception,e:
                break

      这样一个run就可以做到很大程度的解耦了。

      类似的思想,线程池类和线程类也不必是一一对应的。可以将线程类作为一个参数传递给线程池类。这样一个线程池类就可以作为容器容纳各种各样的线程了。具体实例就不写了。

  • 相关阅读:
    Python3+PyMysql
    Python3 pip
    Python日志模块封装
    SVN状态图标无法显示
    添加修改数据库表以及字段描述信息
    群晖 6 控制面板信息中心 空白解决
    nextcloud迁移后报权限问题
    ESXI中第三方sata卡遇到的问题“对 CDROM 映像文件 执行操作失败”
    网站推荐 印章制作大师
    转 黑群晖7.0.1和6.0 中Active Backup for Business套件激活方法
  • 原文地址:https://www.cnblogs.com/franknihao/p/6627857.html
Copyright © 2020-2023  润新知