网络爬虫(一):爬虫基础原理

本文介绍了网络爬虫的基础知识,包括HTTP基础原理,如HTTP请求过程、响应状态码、响应头和响应体。详细讲解了网页的组成、CSS和JavaScript的作用。此外,文章阐述了爬虫的基本原理,包括获取网页、提取信息和保存数据的步骤。同时,探讨了cookie与session在爬虫中的应用,以及多线程和多进程的基本概念,讨论了Python中多线程和多进程的实现方式,如互斥锁、进程池等,并分析了Python多线程受限于GIL的问题。

一、学习前言

学习完Python语言后,总觉得有难以用武之地,纸上学来终觉浅,绝知此事要躬行,如果不加以使用就很容易忘记,为了加深python语言的记忆和理解,我选择学习网络爬虫的技术来进一步提升自己的能力。

其次觉得爬虫是一项必须掌握的技术,有很多用武之地,看似简单却趣味无穷,入门容易但真的想做好也是需要下一番功夫的。经过我的一番了解和学习,爬虫技术也有很深的奥秘,爬虫技术也很容易失效,企业为了保护自己的数据不被轻易地爬取,采取了非常多的反爬虫措施,如 JavaScript 混淆和加密、App 加密、增强型验证码、封锁 IP、封锁账号等,甚至有不少企业有专门的更难破解的反爬措施,需要不断更新相关技术才能跟上时代的步伐,所以在此做一个记录,以便不断地要求自己更新知识和技能面。

另外,爬虫涉及的面很广,对计算机网络、编程基础、前端开发、后端开发、App 开发与逆向、网络安全、数据库、运维、机器学习、数据分析等方向也有一定的要求。我相信python和爬虫技术对我以后的学习和工作也有很大的帮助。在这里也非常感谢拉勾教育网提供的学习资料。

 

二、爬虫基础原理

2.1 HTTP基础原理

URI 的全称为 Uniform Resource Identifier,即统一资源标志符

URL 的全称为 Universal Resource Locator,即统一资源定位符

在淘宝的首页https://www.taobao.com/中,URL的开头会有http或https,这个就是访问资源需要的协议类型,有时我们还会看到ftp、sftp、smb开头的URL,这里的 ftp、sftp、smb 都是指的协议类型。在爬虫中,我们抓取的页面通常就是 http 或 https 协议的

HTTP的全称是HyperTextTransferProtocol,中文名叫作超文本传输协议,HTTP协议是用于从网络传输超文本数据到本地浏览器的传送协议,它能保证高效而准确地传送超文本文档,目前广泛使用的是 HTTP 1.1 版本。

HTTPS的全称是HyperTextTransferProtocoloverSecureSocketLayer,是以安全为目标的HTTP通道,简单讲是HTTP的安全版,即HTTP 下加入 SSL 层,简称为 HTTPS。

HTTPS 的安全基础是 SSL,因此通过它传输的内容都是经过 SSL 加密的,它的主要作用可以分为两种:

1、建立一个信息安全通道,来保证数据传输的安全。

2、确认网站的真实性,凡是使用了HTTPS的网站,都可以通过点击浏览器地址栏的锁头标志来查看网站认证之后的真实信息,也可以通过CA机构颁发的安全签章来查询

2.1.1 HTTP请求过程

在浏览器中输入一个URL,回车之后便可以在浏览器中观察到页面内容。实际上,这个过程是浏览器向网站所在的服务器发送了一个请求,网站服务器接收到这个请求后进行处理和解析,然后返回对应的响应,接着传回给浏浏览器。响应里包含了页面的源代码等内容,浏览器再对其进行解析,便将网页呈现了出来,传输模型如图所示。

请求

请求由客户端向服务端发出,可以分为4部分内容:请求方法(RequestMethod)、请求的网址(RequestURL)、请求头(RequestHeaders)、请求体(RequestBody)

请求方法

常见的请求方法有两种:GET 和 POST。

GET和POST请求方法有如下区别

GET请求中的参数包含在URL里面,数据可以在URL中看到,而POST请求的URL不会包含这些数据,数据都是通过表单形式传输的,会包含在请求体中。

GET 请求提交的数据最多只有 1024 字节,而 POST 请求没有限制。

一般来说,登录时,需要提交用户名和密码,其中包含了敏感信息,使用GET方式请求的话,密码就会暴露在URL里面,造成密码泄露,所以这里最好以POST方式发送。上传文件时,由于文件内容比较大,也会选用 POST 方式。

请求方法汇总表:

 

请求头

请求头用来说明服务器要使用的附加信息,比较重要的信息有Cookie、Referer、User-Agent等。下面简要说明一些常用的头信息。

Accept:请求报头域,用于指定客户端可接受哪些类型的信息。

Accept-Language:指定客户端可接受的语言类型。

Accept-Encoding:指定客户端可接受的内容编码。

Host:用于指定请求资源的主机IP和端口号,其内容为请求URL的原始服务器或网关的位置。从HTTP1.1版本开始,请求必须包含此内容。

Cookie:也常用复数形式Cookies,这是网站为了辨别用户进行会话跟踪而存储在用户本地的数据。它的主要功能是维持当前访问会话。例如,我们输入用户名和密码成功登录某个网站后,服务器会用会话保存登录状态信息,后面我们每次刷新或请求该站点的其他页面时,会发现都是登录状态,这就是Cookies的功劳。Cookies里有信息标识了我们所对应的服务器的会话,每次浏览器在请求该站点的页面时,都会在请求头中加上Cookies并将其发送给服务器,服务器通过Cookies识别出是我们自己,并且查出当前状态是登录状态,所以返回结果就是登录之后才能看到的网页内容。

Referer:此内容用来标识这个请求是从哪个页面发过来的,服务器可以拿到这一信息并做相应的处理,如做来源统计、防盗链处理等。

User-Agent:简称UA,它是一个特殊的字符串头,可以使服务器识别客户使用的操作系统及版本、浏览器及版本等信息。在做爬虫时加上此信息,可以伪装为浏览器;如果不加,很可能会被识别出为爬虫。

Content-Type:也叫互联网媒体类型(InternetMediaType)或者MIME类型,在HTTP协议消息头中,它用来表示具体请求中的媒体类型信息。例如,text/html 代表 HTML 格式,image/gif 代表 GIF 图片,application/json 代表 JSON 类型。

因此,请求头是请求的重要组成部分,在写爬虫时,大部分情况下都需要设定请求头。

 

请求体

请求体一般承载的内容是 POST 请求中的表单数据,而对于 GET 请求,请求体则为空。

登录之前,我们填写了用户名和密码信息,提交时这些内容就会以表单数据的形式提交给服务器,此时需要注意RequestHeaders中指定Content-Type为application/x-www-form-urlencoded。只有设置 Content-Type 为 application/x-www-form-urlencoded,才会以表单数据的形式提交。另外,我们也可以将Content-Type设置为application/json来提交JSON数据,或者设置为multipart/form-data来上传文件。

在爬虫中,如果要构造 POST 请求,需要使用正确的 Content-Type,并了解各种请求库的各个参数设置时使用的是哪种 Content-Type,不然可能会导致 POST 提交后无法正常响应。

 

2.1.2 响应

响应,由服务端返回给客户端,可以分为三部分:响应状态码(Response Status Code)、响应头(Response Headers)和响应体(Response Body)。

响应状态码

响应状态码表示服务器的响应状态,如 200 代表服务器正常响应,404 代表页面未找到,500 代表服务器内部发生错误。在爬虫中,我们可以根据状态码来判断服务器响应状态。

响应头包含了服务器对请求的应答信息,如 Content-Type、Server、Set-Cookie 等。

响应体

最重要的当属响应体的内容了。响应的正文数据都在响应体中,比如请求网页时,它的响应体就是网页的HTML代码;请求一张图片时,它的响应体就是图片的二进制数据。我们做爬虫请求网页后,要解析的内容就是响应体。

在浏览器开发者工具中点击Preview,就可以看到网页的源代码,也就是响应体的内容,它是解析的目标。在做爬虫时,我们主要通过响应体得到网页的源代码、JSON数据等,然后从中做相应内容的提取。

 

2.2 Web网页基础

2.2.1 网页的组成

首先,我们来了解网页的基本组成,网页可以分为三大部分:HTML、CSS 和 JavaScript。

HTML 是用来描述网页的一种语言,其全称叫作 Hyper Text Markup Language,即超文本标记语言。

我们浏览的网页包括文字、按钮、图片和视频等各种复杂的元素,其基础架构就是HTML。不同类型的元素通过不同类型的标签来表示,如图片用img标签表示,视频用video标签表示,段落用p标签表示,它们之间的布局又常通过布局标签 div 嵌套组合而成,各种标签通过不同的排列和嵌套就可以形成网页的框架。

CSS

虽然HTML定义了网页的结构,但是只有HTML页面的布局并不美观,可能只是简单的节点元素的排列,为了让网页看起来更好看一些,这里就需要借助CSS了。CSS,全称叫作CascadingStyleSheets,即层叠样式表。“层叠”是指当在HTML中引用了数个样式文件,并且样式发生冲突时,浏览器能依据层叠顺序处理。“样式”指网页中文字大小、颜色、元素间距、排列等格式。CSS 是目前唯一的网页页面排版样式标准,有了它的帮助,页面才会变得更为美观。

在网页中,一般会统一定义整个网页的样式规则,并写入 CSS 文件中(其后缀为 css)。在 HTML 中,只需要用 link 标签即可引入写好的 CSS 文件,这样整个页面就会变得美观、优雅。

JavaScript,简称JS,是一种脚本语言。HTML和CSS配合使用,提供给用户的只是一种静态信息,缺乏交互性。我们在网页里可能会看到一些交互和动画效果,如下载进度条、提示框、轮播图等这通常就是JavaScript的功劳。它的出现使得用户与信息之间不只是一种浏览与显示的关系,而是实现了一种实时、动态、交互的页面功能。JavaScript通常也是以单独的文件形式加载的,后缀为 js,在 HTML 中通过 script 标签即可引入。

网页的结构

最简单的HTML实例

节点树及节点间的关系

在 HTML 中,所有标签定义的内容都是节点,它们构成了一个 HTML DOM 树。

选择器

我们知道网页由一个个节点组成,CSS选择器会根据不同的节点设置不同的样式规则,那么怎样来定位节点呢?在CSS中,我们使用CSS选择器来定位节点。具体可见CSS选择器语法规则。

 

2.3 爬虫基本原理

我们可以把互联网比作一张大网,而爬虫(即网络爬虫)便是在网上爬行的蜘蛛。如果把网的节点比作一个个网页,爬虫爬到这就相当于访问了该页面,获取了其信息。可以把节点间的连线比作网页与网页之间的链接关系,这样蜘蛛通过一个节点后,可以顺着节点连线继续爬行到达下一个节点,即通过一个网页继续获取后续的网页,这样整个网的节点便可以被蜘蛛全部爬行到,网站的数据就可以被抓取下来了。

爬虫就是获取网页并提取和保存信息的自动化程序。

2.3.1 获取网页

        爬虫首先要做的工作就是获取网页,这里就是获取网页的源代码。源代码里包含了网页的部分有用信息,所以只要把源代码获取下来,就可以从中提取想要的信息了。前面讲了请求和响应的概念,向网站的服务器发送一个请求,返回的响应体便是网页源代码。所以,最关键的部分就是构造一个请求并发送给服务器,然后接收到响应并将其解析出来。

流程如何实现呢?

Python提供了许多库来帮助我们实现这个操作,如urllib、requests等。我们可以用这些库来帮助我们实现HTTP请求操作,请求和响应都可以用类库提供的数据结构来表示,得到响应之后只需要解析数据结构中的 Body 部分即可,即得到网页的源代码,这样我们可以用程序来实现获取网页的过程了。

2.3.2 提取信息

获取网页源代码后,接下来就是分析网页源代码,从中提取我们想要的数据。首先,最通用的方法便是采用正则表达式提取,这是一个万能的方法,但是在构造正则表达式时比较复杂且容易出错。另外,由于网页的结构有一定的规则,所以还有一些根据网页节点属性、CSS选择器或XPath来提取网页信息的库,如BeautifulSoup、pyquery、lxml等。使用这些库,我们可以高效快速地从中提取网页信息,如节点的属性、文本值等。

提取信息是爬虫非常重要的部分,它可以使杂乱的数据变得条理清晰,以便我们后续处理和分析数据。

2.3.3 保存数据

提取信息后,我们一般会将提取到的数据保存到某处以便后续使用。这里保存形式有多种多样,如可以简单保存为TXT文本或JSON文本,也可以保存到数据库,如MySQL和MongoDB等,还可保存至远程服务器,如借助SFTP进行操作等。

2.3.4 自动化程序

自动化程序是说爬虫可以代替人来完成这些操作。首先,我们手工当然可以提取这些信息,但是当量特别大或者想快速获取大量数据的话,肯定还是要借助程序。爬虫就是代替我们来完成这份爬取工作的自动化程序,它可以在抓取过程中进行各种异常处理、错误重试等操作,确保爬取持续高效地运行。

2.3.5 能抓怎样的数据

在网页中我们能看到各种各样的信息,最常见的便是常规网页,它们对应着 HTML 代码,而最常抓取的便是 HTML 源代码

另外,可能有些网页返回的不是 HTML 代码,而是一个 JSON 字符串(其中 API 接口大多采用这样的形式),这种格式的数据方便传输和解析,它们同样可以抓取,而且数据提取更加方便。

还可以看到各种二进制数据,如图片、视频和音频等。利用爬虫,我们可以将这些二进制数据抓取下来,然后保存成对应的文件名。另外,还可以看到各种扩展名的文件,如CSS、JavaScript和配置文件等,这些其实也是最普通的文件,只要在浏览器里面可以访问到,就可以将其抓取下来。

上述内容其实都对应各自的 URL,是基于 HTTP 或 HTTPS 协议的,只要是这种数据,爬虫都可以抓取。

2.3.5 JavaScript渲染页面

有时候,我们在用urllib或requests抓取网页时,得到的源代码实际和浏览器中看到的不一样。这是一个非常常见的问题。现在网页越来越多地采用Ajax、前端模块化工具来构建,整个网页可能都是由 JavaScript 渲染出来的,也就是说原始的 HTML 代码就是一个空壳,例如:

<!DOCTYPE  html>
<html>
<head>
<metacharset="UTF-8">
<title>ThisisaDemo</title>
</head>
<body>
<div id="container">
</div>
</body>
<script src="app.js"></script>
</html>

body节点里面只有一个id为container的节点,但是需要注意在body节点后引入了app.js,它便负责整个网站的渲染。在浏览器中打开这个页面时,首先会加载这个HTML内容,接着浏览器会发现其中引入了一个app.js文件,然后便会接着去请求这个文件,获取到该文件后,便会执行其中的JavaScript代码,而JavaScript则会改变HTML中的节点,向其添加内容,最后得到完整的页面。但是在用urllib或requests等库请求当前页面时,我们得到的只是这个HTML代码,它不会帮助我们去继续加载这个JavaScript文件,这样也就看不到浏览器中的内容了。这也解释了为什么有时我们得到的源代码和浏览器中看到的不一样。因此,使用基本HTTP请求库得到的源代码可能跟浏览器中的页面源代码不太一样。对于这这样的情况,我们可以分析其后台Ajax接口,也可使用Selenium、Splash这样的库来实现模拟JavaScript渲染。

 

2.4 cookie与session

静态网页:由HTML 代码编写的,文字、图片等内容均通过写好的 HTML 代码指定好的,它加载速度快,编写简单,但是存在很大的缺陷,如可维护性差,不能根据 URL 灵活多变地显示内容等。

动态网页:以动态解析URL中参数的变化,关联数据库并动态呈现不同的页面内容,非常灵活多变。我们现在遇到的大多数网站都是动态网站,它们不再是一个简单的HTML,而是可能由JSP、PHP、Python等语言编写,动态网站还可以实现用户登录和注册的功能。很多页面是需要登录之后才可以查看的。按照一般的逻辑来说,输入用户名和密码登录之后,肯定是拿到了一种类似凭证的东西,有了它,我们才能保持登录状态,才能访问登录之后才能看到的页面。这种神秘的凭证就是 Session 和 Cookies 共同产生的结果。

HTTP 的无状态是指 HTTP 协议对事务处理是没有记忆能力的,也就是说服务器不知道客户端是什么状态。当我们向服务器发出请求后服务器解析请求并做出响应。服务器负责完成这个过程,而且这个过程是完全独立的,服务器不会记录前后状态的变化,也就是缺少状态记录。这意味着如果后续需要处理前面的信息,则必须重传,这也导致需要额外传递一些前面的重复请求,才能获取后续响应,然而这种效果显然不是我们想要的。为了保持前后状态,我们肯定不能将前面的请求全部重传一次,这太浪费资源了。这时两个用于保持HTTP连接状态的技术就出现了,它们分别是Session和Cookies。Session在服务端,也就是网站的服务器,用来保存用户的Session信息;Cookies在客户端,也可以理解为浏览器端,有了Cookies,浏览器在下次访问网页时会自动附带上它发送给服务器,服务器通过识别Cookies并鉴定出是哪个用户,然后再判断用户是否是登录状态,进而返回对应的响应。

可以理解为Cookies里面保存了登录的凭证,有了它,只需要在下次请求携带Cookies发送请求而不必重新输入用户名、密码等信息重新登录了。因此在爬虫中,有时候处理需要登录才能访问的页面时,我们一般会直接将登录成功后获取的 Cookies 放在请求头里面直接请求,而不必重新模拟登录。

Session,中文称之为会话,其本身的含义是指有始有终的一系列动作 / 消息。在Web中,Session对象用来存储特定用户Session所需的属性及配置信息。这样,当用户在应用程序的Web页之间跳转时,存储在Session对象中的变量将不会丢失,而是在整个用户Session中一直存在下去。当用户请求来自应用程序的Web页时,如果该用户还没有Session,则Web服务器将自动创建一个Session对象。当Session过期或被放弃后服务器将终止该session。

Cookies 指某些网站为了辨别用户身份、进行 Session 跟踪而存储在用户本地终端上的数据。

Session维持:客户端第一次请求服务器时,服务器会返回一个响应头中带有Set-Cookie字段的响应给客户端,用来标记是哪一个用户,客户端浏览器会把Cookies保存起来。当浏览器下一次再请求该网站时,浏览器会把Cookies保存起来。当浏览器下一次再请求该网站时,浏览器会把此Cookies放到请求头一起提交给服务器,Cookies携带了SessionID信息,服务器检查该Cookies即可找到对应的 Session 是什么,然后再判断 Session 来以此来辨认用户状态。

如果Session中的某些设置登录状态的变量是有效的,那就证明用户处于登录状态,此时返回登录之后才可以查看的网页内容,浏览器再进行解析便可以看到了。反之,如果传给服务器的Cookies是无效的,或者Session已经过期了,我们将不能继续访问页面,此时可能会收到错误的响应或者跳转到登录页面重新登录。所以,Cookies和Session需要配合,一个处于客户端,一个处于服务端,二者共同协作,就实现了登录 Session 控制。

Cookies 的内容:在浏览器开发者工具中打开 Application 选项卡,然后在左侧会有一个 Storage 部分,最后一项即为 Cookies,它的属性如下:

 

会话Cookie和持久Cookie

从表面意思来说,会话Cookie就是把Cookie放在浏览器内存里,浏览器在关闭之后该Cookie即失效;持久Cookie则会保存到客户端的的硬盘中,下次还可以继续使用,用于长久保持用户登录状态。其实严格来说,没有会话Cookie和持久Cookie之分,只是由Cookie的MaxAge或Expires字段决定了过期的时间

因此,一些持久化登录的网站其实就是把 Cookie 的有效时间和 Session 有效期设置得比较长,下次我们再访问页面时仍然携带之前的 Cookie,就可以直接保持登录状态。由于关闭浏览器不会导致Session被删除,这就需要服务器为Session设置一个失效时间,当距离客户端上一次使用Session的时间超过这个失效时间时,服务器就可以认为客户端已经停止了活动,才会把 Session 删除以节省存储空间。

 

2.5 多线程基本原理

为什么计算机可以做到这么多软件同时运行呢?这就涉及到计算机中的两个重要概念:多进程和多线程了。同样,在编写爬虫程序的时候,为了提高爬取效率,我们可能想同时运行多个爬虫任务。这里同样需要涉及多进程和多线程。

2.5.1 进程和线程

进程我们可以理解为是一个可以独立运行的程序单位,比如打开一个浏览器,这就开启了一个浏览器进程,一个进程中是可以同时处理很多事情的,比如在浏览器中,我们可以在多个选项卡中打开多个页面。

进程就是由一个或多个线程构成的,线程是操作系统进行运算调度的最小单位,是进程中的一个最小运行单元。比如上面所说的浏览器进程,其中的播放音乐就是一个线程,这些线程的并发或并行执行最后使得整个浏览器可以同时运行这么多的任务。多线程就是一个进程中同时执行多个线程,前面所说的浏览器的情景就是典型的多线程执行。

2.5.2 并行与并发

并发,英文叫作concurrency。它是指同一时刻只能有一条指令执行,但是多个线程的对应的指令被快速轮换地执行。比如一个处理器,它先执行线程A的指令一段时间,再执行线程B的指令一段时间,再切回到线程A执行一段时间。由于处理器执行指令的速度和切换的速度非常非常快,人完全感知不到计算机在这个过程中有多个线程切换上下文执行的操作,这就使得宏观上看起来多个线程在同时运行。但微观上只是这个处理器在连续不断地在多个线程之间切换和执行,每个线程的执行一定会占用这个处理器一个时间片段,同一时刻,其实只有一个线程在执行。

并行,英文叫作 parallel。它是指同一时刻,有多条指令在多个处理器上同时执行,并行必须要依赖于多个处理器。不论是从宏观上还是微观上,多个线程都是在同一时刻一起执行的。

在一个程序进程中,有一些操作是比较耗时或者需要等待的,比如等待数据库的查询结果的返回,等待网页结果的响应。如果使用单线程,处理器必须要等到这些操作完成之后才能继续往下执行其他操作,而这个线程在等待的过程中,处理器明显是可以来执行其他的操作的。如果使用多线程,处理器就可以在某个线程等待的时候,去执行其他的线程,从而从整体上提高执行效率。像上述场景,线程在执行过程中很多情况下是需要等待的。比如网络爬虫就是一个非常典型的例子,爬虫在向服务器发起请求之后,有一段时间必须要等待服务器的响应返回,这种任务就属于IO密集型任务。对于这种任务,如果我们启用多线程,处理器就可以在某个线程等待的过程中去处理其他的任务,从而提高整体的爬取效率。

2.5.3 Python 实现多线程

在 Python 中,实现多线程的模块叫作 threading,是 Python 自带的模块。使用 threading 实现多线程的方法:

Thread 直接创建子线程

首先,可以使用Thread类来创建一个线程,创建时需要指定target参数为运行的方法名称,如果被调用的方法需要传入额外的参数,则可以通过Thread的args参数来指定。示例如下:

这里首先声明了一个方法,叫作target,它接收一个参数为second,通过方法的实现可以发现,这个方法其实就是执行了一个time.sleep休眠操作,second参数就是休眠秒数,其前后都print了一些内容,其中线程的名字我们通过threading.current_thread().name来获取出来,如果是主线程的话,其值就是MainThread,如果是子线程的话,其值就是Thread-*。然后我们通过Thead类新建了两个线程,target参数就是刚才我们所定义的方法名,args以列表的形式传递。两次循环中,这里i分别就是1和5,这样两个线程就分别休眠1秒和5秒,声明完成之后,我们调用start方法即可开始线程的运行。观察结果我们可以发现,这里一共产生了三个线程,分别是主线程MainThread和两个子线程Thread-1、Thread-2。另外我们观察到,主线程首先运行结束,紧接着Thread-1、Thread-2才接连运行结束,分别间隔了1秒和4秒。这说明主线程并没有等待子线程运行完毕才结束运行,而是直接退出了,有点不符合常理。

如果我们想要主线程等待子线程运行完毕之后才退出,可以让每个子线程对象都调用下 join 方法,实现如下:

继承 Thread 类创建子线程

另外,也可以通过继承 Thread 类的方式创建一个线程,该线程需要执行的方法写在类的 run 方法里面即可。上面的例子的等价改写为:

 守护线程

在线程中有一个叫作守护线程的概念,如果一个线程被设置为守护线程,那么意味着这个线程是“不重要”的,这意味着,如果主线程结束了而该守护线程还没有运行完,那么它将会被强制结束。在Python中我们可以通过 setDaemon 方法来将某个线程设置为守护线程。示例如下:

通过setDaemon方法将t2设置为了守护线程,这样主线程在运行完毕时,t2线程会随着线程的结束而结束。运行结果如下:Threading MainThread is running

Threading Thread-1 is running

Threading Thread-1 sleep 2s

Threading Thread-2 is running

Threading Thread-2 sleep 5s

Threading MainThread is ended

Threading Thread-1 is ended

可以看到,我们没有看到Thread-2打印退出的消息,Thread-2随着主线程的退出而退出了。不过细心的你可能会发现,这里并没有调用join方法,如果我们让t1和t2都调用join方法,主线程就会仍然等待各个子线程执行完毕再退出,不论其是否是守护线程。

 

互斥锁

在一个进程中的多个线程是共享资源的,比如在一个进程中,有一个全局变量 count 用来计数,现在我们声明多个线程,每个线程运行时都给 count 加 1,看看效果如何,代码实现如下:

import threading
import time

count = 0

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

def run(self):
global count
temp = count + 1
time.sleep(0.001)
count = temp

threads = []
for _ in range(1000):
thread = MyThread()
thread.start()
threads.aappend(thread)

for thread in threads:
thread.join()
printf(f'Final count:{count}')

在这里,我们声明了1000个线程,每个线程都是现取到当前的全局变量count值,然后休眠一小段时间,然后对count赋予新的值。那这样,按照常理来说,最终的count值应该为1000。但是运行最后的结果居然只有69,而且多次运行或者换个环境运行结果是不同的。这是为什么呢?因为count这个值是共享的,每个线程都可以在执行temp=count这行代码时拿到当前count的值,但是这些线程中的一些线程可能是并发或者并行执行的,这就导致不同的线程拿到的可能是同一个count值,最后导致有些线程的count的加1操作并没有生效,导致最后的结果偏小。

所以,如果多个线程同时对某个数据进行读取或修改,就会出现不可预料的结果。为了避免这种情况,我们需要对多个线程进行同步,要实现同步,我们可以对需要操作的数据进行加锁保护,这里就需要用到threadin.Lock了。加锁保护是什么意思呢?就是说,某个线程在对数据进行操作前,需要先加锁,这样其他的线程发现被加锁了之后,就无法继续向下执行,会一直等待锁被释放,只有加锁的线程把锁释放了,其他的线程才能继续加锁并对数据做修改,修改完了再释放锁。这样可以确保同一时间只有一个线程操作数据,多个线程不会再同时读取和修改同一个数据,这样最后的运行结果就是对的了。可以将代码修改为如下内容:

import threading
import time

count = 0

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

def run(self):
global count
lock.require()    //
temp = count + 1
time.sleep(0.001)
count = temp
lock.release()   //

lock = threading.Lock()   //互斥锁
threads = []
for _ in range(1000):
thread = MyThread()
thread.start()
threads.aappend(thread)

for thread in threads:
thread.join()
printf(f'Final count:{count}')

在这里我们声明了一个lock对象,其实就是threading.Lock的一个实例,然后在run方法里面,获取count前先加锁,修改完count之后再释放锁,这样多个线程就不会同时获取和修改 count 的值了。运行结果如下:

Final count: 1000

 

Python多线程的问题

由于Python中GIL的限制,导致不论是在单核还是多核条件下,在同一时刻只能运行一个线程,导致Python多线程无法发挥多核并行的优势。GIL全称Global Interpreter Lock,中文翻译为全局解释器锁,其最初设计是出于数据安全而考虑的。在Python多线程下,每个线程的执行方式如下:

  • 获取GIL
  • 执行对应线程的代码
  • 释放GIL

可见,某个线程想要执行,必须先拿到GIL,我们可以把GIL看作是通行证,并且在一个Python进程中,GIL只有一个。拿不到通行证的线程,就不允许执行。这样就会导致,即使是多核条件下,一个Python进程下的多个线程,同一时刻也只能执行一个线程。不过对于爬虫这种IO密集型任务来说,这个问题影响并不大。而对于计算密集型任务来说,由于GIL的存在,多线程总体的运行效率相比可能反而比单线程更低。

 

2.6 多进程基本原理

2.6.1 多进程及其优势

进程(Process)是具有一定独立功能的程序关于某个数据集合上的一次运行活动,是系统进行资源分配和调度的一个独立单位。顾名思义,多进程就是启用多个进程同时运行。由于进程是线程的集合,而且进程是由一个或多个线程构成的,所以多进程的运行意味着有大于或等于进程数量的线程在运行。Python多进程的优势通过上一课时我们知道,由于进程中GIL的存在,Python中的多线程并不能很好地发挥多核优势,一个进程中的多个线程,在同一时刻只能有一个线程运行。而对于多进程来说,每个进程都有属于自己的GIL,所以,在多核处理器下,多进程的运行是不会受GIL的影响的。因此,多进程能更好地发挥多核的优势。当然,对于爬虫这种 IO 密集型任务来说,多线程和多进程影响差别并不大。不过值得注意的是,由于进程是系统进行资源分配和调度的一个独立单位,所以各个进程之间的数据是无法共享的,如多个进程无法共享一个全局变量,进程之间的数据共享需要有单独的机制来实现。

2.6.2 多进程的实现

      在Python中也有内置的库来实现多进程,它就是multiprocessing。multiprocessing提供了一系列的组件,如Process(进程)、Queue(队列)、Semaphore(信号量)、Pipe(管道)、Lock(锁)、Pool(进程池)等,接下来让我们来了解下它们的使用方法。

a 、直接使用Process类

每一个进程都用一个 Process 类来表示。它的 API 调用如下:

Process([group [, target [, name [, args [, kwargs]]]]])

  • target表示调用对象,你可以传入方法的名字。
  • args表示被调用对象的位置参数元组,比如target是函数func,他有两个参数m,n,那么args就传入[m,n]即可。
  • kwargs 表示调用对象的字典。
  • name 是别名,相当于给这个进程取一个名字。
  • group 分组。

实例:

这是一个实现多进程最基础的方式:通过创建Process来新建一个子进程,其中target参数传入方法名,args是方法的参数,是以元组的形式传入,其和被调用的方法process的参数是一一对应的。

注意:这里args必须要是一个元组,如果只有一个参数,那也要在元组第一个元素后面加一个逗号,如果没有逗号则和单个元素本身没有区别,无法构成元组,导致参数传递出现问题。创建完进程之后,我们通过调用start方法即可启用进程

multiprocessing还提供了几个比较有用的方法,如我们可以通过cpu_count的方法来获取当前机器CPU的核心数量,通过active_children方法获取当前还在运行的所有进程。实例:

我们通过 cpu_count 成功获取了 CPU 核心的数量。还通过active_children获取到了当前正在活跃运行的进程列表。然后我们遍历了每个进程,并将它们的名称和进程号打印出来了,这里进程号直接使用pid属性即可获取,进程名称直接通过name属性获取。

b、继承Process类

在上面的例子中,我们创建进程是直接使用Process这个类来创建的,这是一种创建进程的方式。不过,创建进程的方式不止这一种,同样,我们也可以像线程Thread一样来通过继承的方式创建一个进程类,进程的基本操作我们在子类的 run 方法中实现即可。实例:

首先声明了一个构造方法,这个方法接收一个loop参数,代表循环次数,并将其设置为全局变量。在run方法中,又使用这个loop变量循环了loop次并打印了当前的进程号和循环次数。

在调用时,我们用range方法得到了2、3、4三个数字,并把它们分别初始化了MyProcess进程,然后调用start方法将进程启动起来。注意:这里进程的执行逻辑需要在run方法中实现,启动进程需要调用 start 方法,调用之后 run 方法便会执行。

c、守护进程

在多进程中,同样存在守护进程的概念,如果一个进程被设置为守护进程,当父进程结束后,子进程会自动被终止,我们可以通过设置 daemon 属性来控制是否为守护进程。实例:

运行结果如下:Main Process ended
结果很简单,因为主进程没有做任何事情,直接输出一句话结束,所以在这时也直接终止了子进程的运行。

这样可以有效防止无控制地生成子进程。这样的写法可以让我们在主进程运行结束后无需额外担心子进程是否关闭,避免了独立子进程的运行。

d、进程等待

上面的运行效果其实不太符合我们预期:主进程运行结束时,子进程(守护进程)也都退出了,子进程什么都没来得及执行。能不能让所有子进程都执行完了然后再结束呢?当然是可以的,只需要加入join 方法即可,我们可以将代码改写如下:       

在调用 start 和 join 方法后,父进程就可以等待所有子进程都执行完毕后,再打印出结束的结果。

默认情况下,join是无限期的。也就是说,如果有子进程没有运行完毕,主进程会一直等待。这种情况下,如果子进程出现问题陷入了死循环,主进程也会无限等待下去。怎么解决这个问题呢?可以给join方法传递一个超时参数,代表最长等待秒数。如果子进程没有在这个指定秒数之内完成,会被强制返回,主进程不再会等待。也就是说这个参数设置了主进程等待该子进程的最长时间。如 p.join(1)

e、终止进程

当然,终止进程不止有守护进程这一种做法,我们也可以通过 terminate 方法来终止某个子进程,另外我们还可以通过 is_alive 方法判断进程是否还在运行。

注意:在调用 terminate 方法之后,记得要调用一下 join 方法,这里调用 join 方法可以为进程提供时间来更新对象状态,用来反映出最终的进程终止效果。

f、进程互斥锁

有的输出结果没有换行是由多个进程并行执行导致的,两个进程同时进行了输出,结果第一个进程的换行没有来得及输出,第二个进程就输出了结果,导致最终输出没有换行

进程互斥,避免了多个进程同时抢占临界区(输出)资源。我们可以通过multiprocessing中的Lock来实现。Lock,即锁,在一个进程输出时,加锁,其他进程等待。等此进程执行结束后,释放锁,其他进程可以进行输出。

我们首先实现一个不加锁的实例,代码如下:

 运行结果会出现问题,如果取消掉刚才代码中的两行注释,重新运行,就会正常输出。

g、信号量

进程互斥锁可以使同一时刻只有一个进程能访问共享资源,如上面的例子所展示的那样,在同一时刻只能有一个进程输出结果。但有时候我们需要允许多个进程来访问共享资源,同时还需要限制能访问共享资源的进程的数量。

这种需求该如何实现呢?可以用信号量,信号量是进程同步过程中一个比较重要的角色。它可以控制临界资源的数量,实现多个进程同时访问共享资源,限制进程的并发量。可以用 multiprocessing 库中的 Semaphore 来实现信号量。

进程之间利用 Semaphore 做到多个进程共享资源,同时又限制同时可访问的进程数量的实例:

如上代码实现了经典的生产者和消费者问题。它定义了两个进程类,一个是消费者,一个是生产者。这里使用multiprocessing中的Queue定义了一个共享队列,然后定义了两个信号量Semaphore,一个代表缓冲区空余数,一个表示缓冲区占用数。生产者Producer使用acquire方法来占用一个缓冲区位置,缓冲区空闲区大小减1,接下来进行加锁,对缓冲区进行操作,然后释放锁,最后让代表占用的缓冲区位置数量加 1,消费者则相反。

h、队列

在上面的例子中我们使用Queue作为进程通信的共享队列使用。而如果我们把上面程序中的Queue换成普通的list,是完全起不到效果的,因为进程和进程之间的资源是不共享的。即使

在一个进程中改变了这个list,在另一个进程也不能获取到这个list的状态,所以声明全局变量对多进程是没有用处的。那进程如何共享数据呢?可以用Queue,即队列。当然这里的队列指的是multiprocessing 里面的 Queue。

生产者不断向Queue里面添加随机数,消费者不断从队列里面取随机数。生产者在放数据的时候调用了Queue的put方法,消费者在取的时候使用了get方法,这样我们就通过Queue实现两个进程的数据共享了。

g、管道

刚才我们使用Queue实现了进程间的数据共享,那么进程之间直接通信,如收发信息,用什么比较好呢?可以用Pipe,管道。管道,我们可以把它理解为两个进程之间通信的通道。管道可以是单向的,即half-duplex:一个进程负责发消息,另一个进程负责收消息;也可以是双向的duplex,即互相收发消息。默认声明Pipe对象是双向管道,如果要创建单向管道,可以在初始化的时候传入 deplex 参数为 False。

i、进程池

我们讲了可以使用Process来创建进程,同时也讲了如何用Semaphore来控制进程的并发执行数量。假如现在我们遇到这么一个问题,我有10000个任务,每个任务需要启动一个进程来来执行,并且一个进程运行完毕之后要紧接着启动下一个进程,同时我还需要控制进程的并发数量,不能并发太高,不然 CPU 处理不过来(如果同时运行的进程能维持在一个最高恒定值当然利用率是最高的)。如何解决呢?

multiprocessing中的Pool。Pool可以提供指定数量的进程,供用户调用,当有新的请求提交到pool中时,如果池还没有满,就会创建一个新的进程用来执行该请求;但如果池中的进程数已经达到规定最大值,那么该请求就会等待,直到池中有进程结束,才会创建新的进程来执行它。实例:

在这个例子中我们声明了一个大小为3的进程池,通过processes参数来指定,如果不指定,那么会自动根据处理器内核来分配进程数。接着我们使用apply_async方法将进程添加进去,args可以用来传递参数。

最后,我们要记得调用close方法来关闭进程池,使其不再接受新的任务,然后调用join方法让主进程等待子进程的退出,等子进程运行完毕之后,主进程接着运行并结束。不过上面的写法多少有些烦琐,这里再介绍进程池一个更好用的map方法,可以将上述写法简化很多。map方法是怎么用的呢?第一个参数就是要启动的进程对应的执行方法,第2个参数是一个可迭代对象,其中的每个元素会被传递给这个执行方法。举个例子:现在我们有一个list,里面包含了很多URL,另外我们也定义了一个方法用来抓取每个URL内容并解析,那么我们可以直接在map的第一个参数传入方法名,第2个参数传入URL列表。实例:

这个例子中我们先定义了一个scrape方法,它接收一个参数url,这里就是请求了一下这个链接,然后输出爬取成功的信息,如果发生错误,则会输出爬取失败的信息。首先我们要初始化一个Pool

指定进程数为3。然后我们声明一个urls列表,接着我们调用了map方法,第1个参数就是进程对应的执行方法,第2个参数就是urls列表,map方法会依次将urls的每个元素作作为 scrape 的参数传递并启动一个新的进程,加到进程池中执行。

运行结果如下:

URL https://www.baidu.com Scraped

URL http://xxxyxxx.netnot Scraped

URL http://blog.youkuaiyun.com/Scraped

URL http://www.meituan.com/Scraped

这样,我们就可以实现3个进程并行运行。不同的进程相互独立地输出了对应的爬取结果。

可以看到,利用 Pool 的 map 方法非常方便地实现了多进程的执行。

后面我们也会在实战案例中结合进程池来实现数据的爬取。

 

 

 

 

 

 

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值