前言

在入门python后,第一件事当然是上手爬虫项目,比如爬爬图片什么的,练练手。的确,简单的小爬虫可以用很简短的代码逻辑便可实现业务需求。但是后来发现,爬虫速度实在太慢了,特别是下载图片的时候,苦苦等待,心想:能否为爬虫提速呢?

 

于是乎,经过一番折腾摸索,才发现了线程和进程的概念。小弟不才,以下仅仅只是个人的一些学习总结,如有纰漏或错误,烦请各位看官指正讨论,一起营造学习氛围。

 

1. 多进程

进程(process),其实就是我们电脑中的每一个任务,如打开并运行中的QQ便是一个进程。事实上,我们可以打开任务管理器(Task Manager),就可了解到电脑目前正在运行的的所有进程。

 

值得注意的是,单核CPU一次只能运行一个进程(任务)!

但我们的电脑CPU不过4核,或8核,那为什么我们平时却可以打开那么多的任务同时处理呢?也就是俗称的“多任务工作”,那这不就核上述定义有所出入了吗?

 

非也!之所以可以在单核CPU上运行多个进程(表面现象),是因为错觉的误导!实际上,并表面看似同时运行的进程,在暗地里是逐个单一运行的。这里就要牵涉到“并发”和“并行”的概念了

 

1.1 并发

并发”(Concurrent),在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行。

 

1.2 并行

“并行”(Parallel),当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,这种方式我们称之为并行

 

参考知乎某一回答(本人改进版):

1. 你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。 ==>单线程滴同步干活!(做完A,再做B)

2. 你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。 ==>(A,B任务都由同一个人(处理机,or cpu)启动了,A,B互相切换的进行)

3. 你吃饭吃到一半,电话来了,你一边吃饭,你妈妈一边接电话说话,这说明你的家支持并行。 ==>(A,B两个任务 同时 进行!你:是一个核心,你妈:是另外一个核心)

并发的关键是你有处理多个任务的能力,不一定要同时。
并行的关键是你有同时处理多个任务的能力。
所以我认为它们最关键的点就是:是否是『同时』

 

参考:并发和并行有什么区别?​cloud.tencent.com

 

所以,回到刚才提出的那个问题,为什么单核cpu的计算机表面上看好像也能多进程(多任务)执行呢? 实际上,这压根就不是多任务“同时(并行)”执行,不过是一种并发执行罢了。也就是先执行了A任务比如0.1s,然后切换执行B任务0.2s之类,速度之快,让我们觉得同时执行而已!

 

也就是说,单核cpu每次只能执行一个进程依旧是成立的!

而多进程的同时执行,必定需要运用多核,也就是得并行了

 

2. 多线程

说完进程,再来谈谈线程。如果说单核cpu是一个工厂,而工厂内虽有许多车间(进程/任务),但每次工厂的电力(系统资源)只足够供一个车间(进程)运行,而每个车间可以至少对应1个或以上工人在工作(线程)。

 

这里的车间便是一个进程,而工人便是线程。可以看出,一个进程必定最少包含一个线程或以上。车间的空间是工人们共享的,比如许多房间是每个工人都可以进出的。这象征一个进程的内存空间是共享的,每个线程都可以使用这些共享内存。

 

i. 可是,每间房间的大小不同,有些房间最多只能容纳一个人,比如厕所。里面有人的时候,其他人就不能进去了。这代表一个线程使用某些共享内存时,其他线程必须等它结束,才能使用这一块内存。

 

一个防止他人进入的简单方法,就是门口加一把。先到的人锁上门,后到的人看到上锁,就在门口排队,等锁打开再进去。这就叫"互斥锁"(Mutual exclusion,缩写 Mutex),防止多个线程同时读写某一块内存区域。

 

ii. 还有些房间,可以同时容纳n个人,比如厨房。也就是说,如果人数大于n,多出来的人只能在外面等着。这好比某些内存区域,只能供给固定数目的线程使用。

 

这时的解决方法,就是在门口挂n把钥匙。进去的人就取一把钥匙,出来时再把钥匙挂回原处。后到的人发现钥匙架空了,就知道必须在门口排队等着了。这种做法叫做"信号量"(Semaphore),用来保证多个线程不会互相冲突。

 

不难看出,mutex是semaphore的一种特殊情况(n=1时)。也就是说,完全可以用后者替代前者。但是,因为mutex较为简单,且效率高,所以在必须保证资源独占的情况下,还是采用这种设计。

 

参考:进程与线程的一个简单解释 - 阮一峰的网络日志​www.ruanyifeng.com


 

常见问题:

问1:市场上所谓的八核,四核处理器的核心数与真实的cpu个数有什么区别?

  • CPU核心数量与CPU数量:一个核心,在此时此刻,只能处理一个任务。一个核心,处理多任务的方法,要么排队,一个一个的。要么一个执行一小会儿,在多个任务间切换。当然了,没有被服务的任务只能等待了,表现就是你的电脑有点卡。如果你的CPU有两个核心,那么它的每个核心,在此时此刻,可以分别服务一个任务。这样可以减少等待,所以你看看CPU的发展史,从一开始的提高时钟频率,到后来的增加核心数。你有多个核心,就相当于你有很多个工人给你干活。你有一个核心,就相当于你只有一个工人给你干活。很明白了吧。那多核CPU不就是工人组团吗。然后CPU核心的时钟频率就是工人的工作效率。你当然喜欢工作效率高的工人。当然你还可以给工人施加压力,让他们超负荷工作,当然是有把工人累坏的风险。这就是CPU超频。

 

问2:是四个cpu核心就能理论上支持四个并行运算吗?

  • 多核心CPU就可以并行了。没错。

 

问3:通常说的多线程开发,多进程开发是真正意义上的并行还是并发?

  • 多线程,多进程是并行还是并发取决于你的CPU核心数量。如果是单核CPU,多线程也没用。如果是多核心CPU,那么就可以并行了。CPU多一个核心,这个多出来的核心就可以多处理一个线程。

 

问4:异步和并发的区别在哪里?他们的实现原理分别是什么?

  • 异步对应的概念是同步。多线程是实现异步的方法。多线程并行(同时进行)(这属于异步)依赖于多核心。多线程并发(把核心比作勺子,把每个线程比作人,把线程的任务比作喝汤。只有一个勺子(单核),一人一口轮着喝(并发))(也属于异步)不依赖与多核心。把一个马桶比作一个CPU核心,线程比做人,那么一个马桶,蹲这个马桶就属于同步,不能拉一半换另一个人拉,然后再换回来接着拉。必须上一个人拉完了,下一个人才能进来拉。

 

参考:大江狗:一文看懂Python多进程与多线程编程(工作学习面试必读)​zhuanlan.zhihu.com


 

补充:

GIL锁

刚才上面多线程提到,可以设定“锁”来控制线程切换,如只有1把锁的时候(上厕所),只有单一线程跑动,只有等他跑完(上完厕所),下一个线程才可以获得这把锁去上。如果是10把锁的时候(如去厨房干活),如果10把锁都被线程获取完毕了,其他人(线程)只能等待,等有锁空出来,才能获得锁从而干活,而获得这10把锁的人,理论上来说是在同时干活的(并行),假设,电脑cpu有10个核心,如果查看任务管理器,理论上看到cpu占有率应该是1000%,但实际上可能才140%左右,只占用了1个核心多一些而已!(并发运行

 

然而这是为什么呢??

 

这其实是由于Python和其他编程语言不同,存在一个全局解释器锁(GIL锁),在执行多线程的时候,哪怕我们没给这些线程上锁,如上锁10个,正常情况下只有这10个获得锁的才能并行运行,其他线程被阻塞等待。而没上锁的话,理论上所有被创建出来的线程都应该是并行处理,同时运行的!,但实际上却是并发的!!这背后便是因为python本身就上了一把GIL锁导致的,从而阻塞了其他线程。这也就是为什么许多人会说:python的多线程就是鸡肋。

 

然而这句话,对,也不全对!

是不是意味着python的多线程毫无意义呢?---当然不是!

python的多线程要真正派上用场,那就要分辨所要执行的任务是“CPU计算密集型”亦或“IO密集型”

 

CPU计算密集型 vs IO密集型

CPU计算密集型任务:指该被执行任务,需要消耗大量的cpu计算资源,如运算大量数据等。

而IO密集型任务:则指需要大量写入写出的任务,如图片,视频,音频的写入等,在python里很好的一个例子就是爬虫,需要大量的写入文件操作。

 

python的多线程能真正的发挥作用的,便是IO密集型任务,不需要占用太多的CPU计算资源,在等待文件写入时,可以不必等完才执行。从而提高效率!!!

所以:在python中,如果是IO密集型,推荐多线程,计算密集型推荐多进程

 

项目:Python爬虫利用多线程提升效率爬取图片!

代码详情:ChanForWang/python-crawl-pic-by-using-Multi-thread (github.com)​github.com

 

import requests
import parsel
import threading
import os
import time

#图片地址
# https://www.jpxgmn.cc/YouMi/  + upload。。。。

'''
need:
1:相册自建立
2:input相册网址智能下载
3:所有站都可:如优美etc
'''


# 设置最大线程锁 =>  等同于10个线程一起做,然后只有这10个线程的锁release了,
# 才会给下面另外10个线程获得锁。  如:下载30个任务,有10个线程, 前10个获得锁,只有等这10个下完了,第11到20才能获得锁,
# 其实就是规定只有10个线程在跑,其他的没有获得锁的线程,就被阻塞了

#然而如果不加这把锁,便代表有几个图片,就有几个线程在跑。

#if value=1, 其实就是单线程在跑,就和普通版一模一样。需要完全等待下载完成,锁释放,才轮到下一个。而value=10,有10把锁,这10个线程可以同时下载,不用非等下载完成。但第11个就要等待其中的某个线程完成下载,把锁释放,才能获取,从而工作
thread_lock = threading.BoundedSemaphore(value=81)


#请求相册的第一页
def getHtml(url, headers):
    response = requests.get(url, headers)
    response.encoding = response.apparent_encoding
    response = response.text
    return response



def getPagesDataList(url,headers,base_url,img_source):
    response = getHtml(url,headers)
    html_data = parsel.Selector(response)   #return a selector object
    total_page_num_list = html_data.xpath('//article[@class="article-content"]/div[1]/ul/a/text()').extract()
    total_page_num = total_page_num_list[-2]   #获取倒数第二个值作为总页数,因为这个列表的倒数第一个是“下一页”
    print("-----共有{}页".format(total_page_num))

    pages_data = []
    for num in range(int(total_page_num)):
        if num == 0:
            #根据图片地址规则发现,为1时是第二页,为0时,就是第一页,所以保持原地址
            next_img_page_url = url
        else:
            next_img_page_url = base_url + '/{}/'.format(img_source) + url.split("/")[-1].split(".")[0] + '_{}'.format(num) + '.html'
        print(next_img_page_url)

        res = getHtml(next_img_page_url,headers)
        requests.packages.urllib3.disable_warnings()
        pages_data.append(res)

    return pages_data


#获取所有页面的img_urls, 放入一个list
def getImgUrlsList(pages_data):
    img_urls = []
    for page in pages_data:
        html_data = parsel.Selector(page)
        img_url_list = html_data.xpath('//article[@class="article-content"]/p/img/@src').extract()
        img_urls.extend(img_url_list)
    print("------------------{} pics in total".format(len(img_urls)))
    return img_urls


def downloadImg(img_url,headers,num,base_url,file_name):
    if not os.path.exists("jpxgmv.com/" + file_name):
        os.mkdir("jpxgmv.com/" + file_name)

    path = "jpxgmv.com/" + file_name + "/" + str(num)+ ".jpg"
    new_img_url = base_url + img_url
    # verify = False 是为了关闭SSL认证(关于网址重定向的,as requests是基于http的,访问https会重定向)
    img_data = requests.get(new_img_url, headers=headers, verify=False).content
    # 这是去除警告
    requests.packages.urllib3.disable_warnings()

    with open(path,"wb") as f:
        f.write(img_data)
    # 下载完了,解锁
    thread_lock.release()


def main():
    url = input("请输入jpxgmv.comの相册-网址:")
    start_t = time.time()

    headers = {"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36 Edg/91.0.864.37","referer":url}
    img_source = url.split('/')[-2]
    base_url = "https://www.jpxgmn.cc"
    file_name = url.split('/')[-2] + "-" + url.split('/')[-1].split('.')[0]

    pages_data = getPagesDataList(url,headers,base_url,img_source)
    img_urls = getImgUrlsList(pages_data)

    num = 0
    for img_url in img_urls:
        num += 1
        print('Downloading No.{} Pic'.format(num))

        #上锁
        thread_lock.acquire()
        t = threading.Thread(target=downloadImg, args=(img_url,headers,num,base_url,file_name))
        t.start()

    print("-------------------------------------DONE!!")
    end_t = time.time()
    cost = end_t - start_t
    print("爬取用时:{} seconds".format(cost))




main()
延伸阅读
  1. 上一篇: Git和GitHub的入门简介
  2. 下一篇: Python爬虫-爬取Instagram图片(涉及瀑布流,异步加载,Json概念)

发表评论

您尚未登录,登录后方可评论~~
登陆 or 注册

评论列表

暂无评论哦~~