浅谈python多线程与多进程

多线程爬虫

从事爬虫工作的程序猿都会遇到有时候页面较多、下载图片等比较耗时的情形,如果只采用传统的方式一个接一个的爬取,那将大大加大采集时间成本,显然不是我们想要的,那么就可以采用多线程的方式分布式采集。

什么是多线程

简单通俗的可以理解为:同等量的工总量多人同时进行,相对于一个人做,便可以极大提升效率,也就是说线程是在同一时间需要完成多项任务,但是多线程的出现就是为了提高效率。同时它的出现也带来了一些问题,具体百科可查看:

https://baike.baidu.com/item/多线程/1190404?fr=aladdin

python线程模块-threading

threading模块是python中专门提供用来做多线程编程的模块。threading模块中最常用的类是Thread。以下看一个简单的多线程程序:

import threading
import time


def test(i):
    print('当前子线程: {} - 任务{}'.format(threading.current_thread().name, i))
    time.sleep(2)
    print("结果: {}".format(6 ** 25))

def main():
    start = time.time()
    print('这是主线程:{}'.format(threading.current_thread().name))
    thread_list = []
    for i in range(1, 4):
        t1 = threading.Thread(target=test, args=(i,))
        thread_list.append(t1)
    for t1 in thread_list:
    	t1.start()

    end = time.time()
    print("总共用时{}秒".format((end - start)))


if __name__=='__main__':
    main()
    # start = time.time()
    # print('这是主线程:{}'.format(threading.current_thread().name))

    # t1 = threading.Thread(target=test, args=(1,))
    # t2 = threading.Thread(target=test, args=(2,))
    # t1.start()
    # t2.start()

    # end = time.time()
    # print("总共用时{}秒".format((end - start)))

结果:

这是主线程:MainThread
当前子线程: Thread-1 - 任务1
当前子线程: Thread-2 - 任务2
当前子线程: Thread-3 - 任务3
总共用时0.000997781753540039秒
结果: 28430288029929701376
结果: 28430288029929701376
结果: 28430288029929701376
以上程序虽然是多线程的一个简单例子,为什么总耗时居然是0秒? 我们可以明显看到主线程和子线程其实是独立运行的,主线程根本没有等子线程完成,而是自己结束后就打印了消耗时间。主线程结束后,子线程仍在独立运行,这显然不是我们想要的。

解决:如果要实现主线程和子线程的同步,我们必需使用join方法

import threading
import time


def test(i):
    print('当前子线程: {} - 任务{}'.format(threading.current_thread().name, i))
    time.sleep(2)
    print("结果: {}".format(6 ** 25))

def main():
    start = time.time()
    print('这是主线程:{}'.format(threading.current_thread().name))
    thread_list = []
    for i in range(1, 4):
        t1 = threading.Thread(target=test, args=(i,))
        thread_list.append(t1)

    # 启动线程
    for t in thread_list:
        t.start()
    # 等待线程结束
    for t in thread_list:
        t.join()
        
    end = time.time()
    print("总共用时{}秒".format((end - start)))

if __name__=='__main__':
    main()

这时你可以看到主线程在等子线程完成后才答应出总消耗时间(2秒),比正常顺序执行代码(6秒)还是节省了不少时间。

这是主线程:MainThread
当前子线程: Thread-1 - 任务1
当前子线程: Thread-2 - 任务2
当前子线程: Thread-3 - 任务3
结果: 28430288029929701376
结果: 28430288029929701376
结果: 28430288029929701376
总共用时2.0026679039001465秒

当我们设置多线程时,主线程会创建多个子线程,在python中,默认情况下主线程和子线程独立运行互不干涉。如果希望让主线程等待子线程实现线程的同步,我们需要使用join()方法。如果我们希望一个主线程结束时不再执行子线程,我们应该怎么办呢? 我们可以使用t.setDaemon(True),如下:

import threading
import time


def test(i):
    print('当前子线程: {} - 任务{}'.format(threading.current_thread().name, i))
    time.sleep(2)
    print("结果: {}".format(6 ** 25))

def main():
    start = time.time()
    print('这是主线程:{}'.format(threading.current_thread().name))
    for i in range(1, 4):
        t1 = threading.Thread(target=test, args=(i,))
        t1.setDaemon(True)
        t1.start()

    end = time.time()
    print("总共用时{}秒".format((end - start)))

if __name__=='__main__':
    main()

结果居然没打出计算结果,是因为主线程没有计算任何数据,所以很快便将结束,而setDaemon设置为True就是主线程结束时不再执行子线程,所以子线程会随之结束,也将不打出结果!

这是主线程:MainThread
当前子线程: Thread-1 - 任务1
当前子线程: Thread-2 - 任务2
当前子线程: Thread-3 - 任务3
总共用时0.0069427490234375秒

继承自threading.Thread类:

可以看到每次开启线程都得实例Thread对象,为了让线程代码更好的封装。可以使用threading模块下的Thread类,继承自这个类,然后重写 run方法,线程就会自动运行run方法中的代码,上面的代码可改为:

import threading
import time


def test(i):
    time.sleep(2)
    return 6**25


class MyThreading(threading.Thread):
    def __init__(self, func, args, name='',):
        threading.Thread.__init__(self)
        self.func = func
        self.args = args
        self.name = name
        self.result = None

    def run(self):
        print('开始子线程{}'.format(self.name))
        self.result = self.func(self.args[0])
        print('结果:{}'.format(self.result))
        print('结束子线程{}'.format(self.name))




def main():
    start = time.time()
    print('这是主线程:{}'.format(threading.current_thread().name))
    thread_list = []
    for i in range(1, 4):
        t1 = MyThreading(test, (i,), str(i))
        thread_list.append(t1)

    # 启动线程
    for t in thread_list:
        t.start()
    # 等待线程结束
    for t in thread_list:
        t.join()


    end = time.time()
    print("总共用时{}秒".format((end - start)))


if __name__=='__main__':
    main()

计算结果可以看出线程结束的顺序是随机的,因为同时进行也会存在计算快慢,所以线程2最快结束也是正常 的:

这是主线程:MainThread
开始子线程1
开始子线程2
开始子线程3
结果:28430288029929701376
结果:28430288029929701376
结束子线程2
结束子线程1
结果:28430288029929701376
结束子线程3
总共用时2.004612684249878秒

以上都是简单的例子,如果在实际开发中遇到各线程需要用到统一变量或数据时,且需要修改,那么如果多个同时要修改,那必定会产生问题,导致最后的结果有偏差。一个进程所含的不同线程间共享内存,这就意味着任何一个变量都可以被任何一个线程修改,因此线程之间共享数据最大的危险在于多个线程同时改一个变量,把内容给改乱了。如果不同线程间有共享的变量,其中一个方法就是在修改前给其上一把锁lock,确保一次只有一个线程能修改它。threading.lock()方法可以轻易实现对一个共享变量的锁定,修改完后release供其它线程使用。比如下例中账户余额balance是一个共享变量,使用lock可以使其不被改乱。

1.先来个不加锁的例子
import threading
import time

count_num = 0

def count_test():
    global count_num
    for i in range(0, 1000000):
        count_num += 1
    print('计算结果:{}'.format(count_num))


def main():
    thread_list = []
    for i in range(2):
        t = threading.Thread(target=count_test)
        # t.start()
        thread_list.append(t)

    # 启动线程
    for t in thread_list:
        t.start()
    # 等待线程结束
    for t in thread_list:
        t.join()


if __name__=='__main__':
    main()

正常计算结果应该为1000000,2000000,可是最终结果却不是,可想是由于全局变量,而多个线程试图修改,而有的修改成功,有的则没修改成功,导致最终的结果不准确(一般会偏小):

结果01:
计算结果:1151199
计算结果:1394151
结果02:
计算结果:1234000
计算结果:1260183
2.加锁的例子
  • threading.Lock():锁
  • acquire:上锁。
  • release:解锁。
import threading
import time


glock = threading.Lock()
count_num = 0

def count_test():
    global count_num
    glock.acquire()
    for i in range(0, 1000000):
        count_num += 1
    glock.release()
    print('计算结果:{}'.format(count_num))


def main():
    thread_list = []
    for i in range(2):
        t = threading.Thread(target=count_test)
        # t.start()
        thread_list.append(t)
    # 启动线程
    for t in thread_list:
        t.start()
    # 等待线程结束
    for t in thread_list:
        t.join()


if __name__=='__main__':
    main()

可以看到加完锁后的结果无误,如下:

计算结果:1000000
计算结果:2000000

除了上面这种加锁的方式实现不同线程间的沟通交流,就是消息队列queue。不像列表,queue是线程安全的,可以放心使用。

使用queue队列通信-经典的生产者和消费者模型

队列

线程间使用队列进行通信,因为队列所有方法都是线程安全的,所以不会出现线程竞争资源的情况。Queue常用的方法。

  • put(item, block=True, timeout=None)
    阻塞方式将item添加进队列中,如果队列满了则一直等待,如果给定了timeout则等待timeout;如果block为Flase,则为非阻塞式,队列满时再添加则直接抛出错误
  • put_nowait(item)
    非阻塞式添加
  • get(block=True, timeout=None)
    阻塞式获取,队列为空时,则一直等待,或者等待给定timeout秒
  • get_nowait()
    非阻塞式获取值
  • qsize()
    返回队列大小
  • empty()
    返回布尔值,判断队列是否为空
  • full()
    返回布尔值,判断队列是否满了
  • join()
    一直阻塞直到队列中的所有项目都已获取并处理完毕。
    每当任务(示例:未爬取过的url)添加到队列时,未完成任务的计数就会增加。 每当消费者线程(示例:爬取网页内容的函数)调用task_done()以指示检索到该项目并且其上的所有工作都已完成时,计数就会下降。 当未完成任务的数量降至零时,join()取消阻塞
  • task_done()
    表明以前排队的任务(示例:使用一个url爬取网页内容完成)已完成。
    由队列使用者线程使用。每次调用get()方法从队列中获取任务,如果任务处理完毕,则条用task_done()方法,告知等待的队列(queue.join()这里在等待)任务的处理已完成。
    如果join()当前正在阻塞,则它将在所有项目都已处理后恢复(这意味着已为每个已放入队列的项目收到task_done()调用)。
    如果调用的次数超过队列中放置的项目,则引发ValueError。

下例中创建了两个线程,一个负责生成,一个负责消费,所生成的产品存放在queue里,实现了不同线程间沟通。

  • 生产者:把待处理的数据put方式加入队列
  • 消费者:把待处理的数据从队列里get方式取出
    下面来个简单的例子:
from queue import Queue
import threading, time, random


# 生产者:生成待处理消息队列
class Producer(threading.Thread):
    def __init__(self, name, queue):
        threading.Thread.__init__(self, name=name)
        self.queue = queue

    def run(self):
        print('我是生产者!!')
        for i in range(1, 10):
            print('线程:{} 正在把{}加入消息队列!!'.format(self.getName(), i))
            self.queue.put(i)
            time.sleep(random.randrange(10) / 3)
        print('%s 结束!' % self.getName())



# 消费者:取出消息队列里的数据
class Consumer(threading.Thread):
    def __init__(self, name, queue):
        threading.Thread.__init__(self, name=name)
        self.queue = queue

    def run(self):
        print('我是消费者!!')
        for i in range(1, 10):
            data = self.queue.get()
            print('线程:{} 正在从消息队列中提取{}!!'.format(self.getName(), data))
            time.sleep(random.randrange(10) / 3)

        print('%s 结束!' % self.getName())


def main():
    queue = Queue()
    pro = Producer('Producer', queue)
    con = Consumer('Consumer', queue)

    #开启线程
    pro.start()
    con.start()
    # 等待线程结束
    pro.join()
    con.join()

    print('所有线程都已结束!!')


if __name__=='__main__':
    main()

运行结果:

我是生产者!!
线程:Producer 正在把1加入消息队列!!
我是消费者!!
线程:Consumer 正在从消息队列中提取1!!
线程:Producer 正在把2加入消息队列!!
线程:Consumer 正在从消息队列中提取2!!
线程:Producer 正在把3加入消息队列!!
线程:Producer 正在把4加入消息队列!!
线程:Consumer 正在从消息队列中提取3!!
线程:Consumer 正在从消息队列中提取4!!
线程:Producer 正在把5加入消息队列!!
线程:Producer 正在把6加入消息队列!!
线程:Producer 正在把7加入消息队列!!
线程:Consumer 正在从消息队列中提取5!!
线程:Consumer 正在从消息队列中提取6!!
线程:Consumer 正在从消息队列中提取7!!
线程:Producer 正在把8加入消息队列!!
线程:Producer 正在把9加入消息队列!!
线程:Consumer 正在从消息队列中提取8!!
线程:Consumer 正在从消息队列中提取9!!
Producer 结束!
Consumer 结束!
所有线程都已结束!!

队列queue的put方法可以将一个对象obj放入队列中。如果队列已满,此方法将阻塞至队列有空间可用为止。queue的get方法一次返回队列中的一个成员。如果队列为空,此方法将阻塞至队列中有成员可用为止。queue同时还自带emtpy(), full()等方法来判断一个队列是否为空或已满,但是这些方法并不可靠,因为多线程和多进程,在返回结果和使用结果之间,队列中可能添加/删除了成员。

多进程爬虫

1.单进程
import os
import time

def count_test():
    print('当前进程:{}'.format(os.getpid()))
    time.sleep(2)
    print('结果:{}'.format(6**25))


def main():
    print('当前母进程:{}'.format(os.getpid()))
    start = time.time()
    for i in range(2):
        count_test()
    end = time.time()
    
    print('总共用时:{}'.format((end - start)))


if __name__=='__main__':
    main()

本实例主要是以计算6的25次方,且单进程计算,且没计算一次等待2秒,结果如下:

当前母进程:2296
当前进程:2296
结果:28430288029929701376
当前进程:2296
结果:28430288029929701376
总共用时:4.030853509902954
2.多进程

利用multiprocess模块的Process方法创建新进程p1和p2来进行并行计算。Process方法接收两个参数, 第一个是target,一般指向函数名,第二个时args,需要向函数传递的参数。对于创建的新进程,调用start()方法即可让其开始。我们可以使用os.getpid()打印出当前进程的名字。

from multiprocessing import Process
import os
import time

def count_test(i):
    print('当前进程:{}-任务{}'.format(os.getpid(), i))
    time.sleep(2)
    print('结果:{}'.format(6**25))


def main():
    print('当前母进程:{}'.format(os.getpid()))
    start = time.time()
    p1 = Process(target=count_test, args=(1,))
    p2 = Process(target=count_test, args=(2,))
    p1.start()
    p2.start()
    p1.join()
    p2.join()
    end = time.time()

    print('总共用时:{}'.format((end - start)))


if __name__=='__main__':
    main()

耗时变为2秒,时间减了一半,可见并发执行的时间明显比顺序执行要快很多。你还可以看到尽管我们只创建了两个进程,可实际运行中却包含里1个母进程和2个子进程。之所以我们使用join()方法就是为了让母进程阻塞,等待子进程都完成后才打印出总共耗时,否则输出时间只是母进程执行的时间。

当前母进程:11088
当前进程:2440-任务1
当前进程:13868-任务2
结果:28430288029929701376
结果:28430288029929701376
总共用时:2.1906402111053467

知识点:

  • 新创建的进程与进程的切换都是要耗资源的,所以平时工作中进程数不能开太大。
  • 同时可以运行的进程数一般受制于CPU的核数。
  • 除了使用Process方法,我们还可以使用Pool类创建多进程。

利用multiprocess模块的Pool类创建多进程

很多时候系统都需要创建多个进程以提高CPU的利用率,当数量较少时,可以手动生成一个个Process实例。当进程数量很多时,或许可以利用循环,但是这需要程序员手动管理系统中并发进程的数量,有时会很麻烦。这时进程池Pool就可以发挥其功效了。可以通过传递参数限制并发进程的数量,默认值为CPU的核数。
Pool类可以提供指定数量的进程供用户调用,当有新的请求提交到Pool中时,如果进程池还没有满,就会创建一个新的进程来执行请求。如果池满,请求就会告知先等待,直到池中有进程结束,才会创建新的进程来执行这些请求。
下面介绍一下multiprocessing 模块下的Pool类的几个方法:

  • 1.apply_async
    函数原型:apply_async(func[, args=()[, kwds={}[, callback=None]]])
    其作用是向进程池提交需要执行的函数及参数, 各个进程采用非阻塞(异步)的调用方式,即每个子进程只管运行自己的,不管其它进程是否已经完成。这是默认方式。
  • 2.map()
    函数原型:map(func, iterable[, chunksize=None])
    Pool类中的map方法,与内置的map函数用法行为基本一致,它会使进程阻塞直到结果返回。 注意:虽然第二个参数是一个迭代器,但在实际使用中,必须在整个队列都就绪后,程序才会运行子进程。
  • 3.map_async()
    函数原型:map_async(func, iterable[, chunksize[, callback]])
    与map用法一致,但是它是非阻塞的。其有关事项见apply_async。
  • 4.close()
    关闭进程池(pool),使其不在接受新的任务。
    1. terminate()
      结束工作进程,不在处理未处理的任务。
  • 6.join()
    主进程阻塞等待子进程的退出, join方法要在close或terminate之后使用。

下例是一个简单的multiprocessing.Pool类的实例。因为小编我的CPU是8核的,一次最多可以同时运行8个进程,所以我开启了一个容量为8的进程池。如果8个进程需要计算10次,你可以想象8个进程并行8次计算任务后,还剩一次计算任务(任务8)没有完成,系统会等待8个进程完成后重新安排2个进程来计算。

from multiprocessing import Pool, cpu_count
import os
import time

def count_test(i):
    print('当前进程.:{}-任务{}'.format(os.getpid(), i))
    time.sleep(2)
    print('结果{}:{}'.format(i, 6**25))


def main():
    print('当前电脑CPU内核数:{}'.format(cpu_count()))
    print('当前母进程:{}'.format(os.getpid()))
    start = time.time()
    p = Pool(8)
    for i in range(10):
        p.apply_async(count_test, args=(i,))

    p.close() #关闭进程池,使其不接受新的任务
    p.join() # 等待所有的进程完成

    end = time.time()

    print('总共用时:{}'.format((end - start)))


if __name__=='__main__':
    main()

知识点:
对Pool对象调用join()方法会等待所有子进程执行完毕,调用join()之前必须先调用close()或terminate()方法,让其不再接受新的Process了。
输出结果如下所示,10个任务(每个任务大约耗时2秒)使用多进程并行计算只需4.45秒, 耗时减少了75%,可见并行计算优势还是很明显的。结果如下:

当前电脑CPU内核数:8
当前母进程:6608
当前进程.:4672-任务0
当前进程.:14648-任务1
当前进程.:7652-任务2
当前进程.:8904-任务3
当前进程.:15192-任务4
当前进程.:12392-任务5
当前进程.:8368-任务6
当前进程.:2996-任务7
结果0:28430288029929701376
结果2:28430288029929701376
结果1:28430288029929701376
结果3:28430288029929701376
当前进程.:4672-任务8
当前进程.:7652-任务9
结果4:28430288029929701376
结果5:28430288029929701376
结果6:28430288029929701376
结果7:28430288029929701376
结果8:28430288029929701376
结果9:28430288029929701376
总共用时:4.450268507003784

相信大家都知道python解释器中存在GIL(全局解释器锁), 它的作用就是保证同一时刻只有一个线程可以执行代码。由于GIL的存在,很多人认为python中的多线程其实并不是真正的多线程,如果想要充分地使用多核CPU的资源,在python中大部分情况需要使用多进程。然而这并意味着python多线程编程没有意义。

多进程间的数据共享与通信

通常,进程之间是相互独立的,每个进程都有独立的内存。通过共享内存(nmap模块),进程之间可以共享对象,使多个进程可以访问同一个变量(地址相同,变量名可能不同)。多进程共享资源必然会导致进程间相互竞争,所以应该尽最大可能防止使用共享状态。还有一种方式就是使用队列queue来实现不同进程间的通信或数据共享,这一点和多线程编程类似。

下例这段代码中中创建了2个独立进程,一个负责写(pw), 一个负责读(pr), 实现了共享一个队列queue。

from multiprocessing import Process, Queue
import os, time, random

# 写数据进程执行的代码:
def write(q):
    print('Process to write: {}'.format(os.getpid()))
    for value in ['A', 'B', 'C']:
        print('Put %s to queue...' % value)
        q.put(value)
        time.sleep(random.random())

# 读数据进程执行的代码:
def read(q):
    print('Process to read:{}'.format(os.getpid()))
    while True:
        value = q.get(True)
        print('Get %s from queue.' % value)

if __name__=='__main__':
    # 父进程创建Queue,并传给各个子进程:
    q = Queue()
    pw = Process(target=write, args=(q,))
    pr = Process(target=read, args=(q,))
    # 启动子进程pw,写入:
    pw.start()
    # 启动子进程pr,读取:
    pr.start()
    # 等待pw结束:
    pw.join()
    # pr进程里是死循环,无法等待其结束,只能强行终止:
    pr.terminate()

结果:

Process to write: 1220
Put A to queue...
Process to read:9296
Get A from queue.
Put B to queue...
Get B from queue.
Put C to queue...
Get C from queue.

Python多进程和多线程哪个快?

由于GIL的存在,很多人认为Python多进程编程更快,针对多核CPU,理论上来说也是采用多进程更能有效利用资源。网上很多人已做过比较,我直接告诉你结论吧。

  • 对CPU密集型代码(比如循环计算) - 多进程效率更高
  • 对IO密集型代码(比如文件操作,网络爬虫) - 多线程效率更高。

为什么是这样呢?其实也不难理解。对于IO密集型操作,大部分消耗时间其实是等待时间,在等待时间中CPU是不需要工作的,那你在此期间提供双CPU资源也是利用不上的,相反对于CPU密集型代码,2个CPU干活肯定比一个CPU快很多。那么为什么多线程会对IO密集型代码有用呢?这时因为python碰到等待会释放GIL供新的线程使用,实现了线程间的切换。

猜你喜欢

转载自blog.csdn.net/Lin_Hv/article/details/105934498