From 248ed54017931f91a765acc7c84ea5a54ecc098c Mon Sep 17 00:00:00 2001 From: jackfrued Date: Tue, 7 Dec 2021 00:30:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=BA=86=E9=83=A8=E5=88=86?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- 第12课:常用数据结构之字典.md | 3 +- 第15课:函数使用进阶.md | 2 +- 第24课:用Python读写Excel文件.md | 194 --------- 第27课:用Python操作PDF文件.md | 4 +- 第29课:用Python发送邮件和短信.md | 3 +- 第31课:网络数据采集概述.md | 200 +++++++++ 第32课:用Python获取网络资源.md | 133 ++++++ 第33课:用Python解析HTML页面.md | 149 +++++++ 第34课:Python中的并发编程-1.md | 382 ++++++++++++++++++ 第35课:Python中的并发编程-2.md | 254 ++++++++++++ 第36课:Python中的并发编程-3.md | 215 ++++++++++ ...课:并发编程在爬虫中的应用.md | 185 +++++++++ 13 files changed, 1529 insertions(+), 201 deletions(-) delete mode 100644 第24课:用Python读写Excel文件.md create mode 100644 第31课:网络数据采集概述.md create mode 100644 第32课:用Python获取网络资源.md create mode 100644 第33课:用Python解析HTML页面.md create mode 100644 第34课:Python中的并发编程-1.md create mode 100644 第35课:Python中的并发编程-2.md create mode 100644 第36课:Python中的并发编程-3.md create mode 100644 第37课:并发编程在爬虫中的应用.md diff --git a/README.md b/README.md index 5595d12..03b08ab 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## Python语言基础50课 -由于之前发布的 Python 学习项目 [Python-100-Days](https://github.com/jackfrued/Python-100-Days) 对初学者来说上手还是有一定难度,所以花了点之间把原来项目中 Python 语言基础部分单独剥离出来,做成了现在这个名为“Python语言基础50课”的项目。现在这个项目用更为简单通俗的方式重写了原来“Python100天”项目中第1天到第15天的部分,**有删减也有补充**,力求**对初学者更加友好**,也欢迎大家关注这个持续更新中的项目。国内用户如果访问 GitHub 比较慢的话,也可以关注我的知乎号 [Python-Jack](https://www.zhihu.com/people/jackfrued) 上的[“从零开始学Python”]()专栏,两边同步更新。有需要的小伙伴可以关注我在知乎的专栏、文章和回答,当然,也欢迎大家评论、收藏和点赞。 +由于之前发布的 Python 学习项目 [Python-100-Days](https://github.com/jackfrued/Python-100-Days) 对初学者来说上手还是有一定难度,所以花了点之间把原来项目中 Python 语言基础部分单独剥离出来,做成了现在这个名为“Python语言基础50课”的项目。现在这个项目用更为简单通俗的方式重写了原来“Python100天”项目中第1天到第15天的部分,**有删减也有补充**,力求**对初学者更加友好**,也欢迎大家关注这个持续更新中的项目。国内用户如果访问 GitHub 比较慢的话,也可以关注我的知乎号 [Python-Jack](https://www.zhihu.com/people/jackfrued) 上的[“从零开始学Python”]()专栏,两边同步更新。有需要的小伙伴可以关注我在知乎的专栏、文章和回答,当然,也欢迎大家评论、收藏和点赞。如果需要**视频教程**,可以到“B站”上搜索[《Python零基础快速上手》](https://www.bilibili.com/video/BV1FT4y1R7sz)。 最近,国内访问 GitHub 会因为 DNS(域名解析服务)的问题出现**图片无法显示**的情况,如果你也遇到了这样的问题,可以通过**修改本机的 hosts 文件**直接对 GitHub 的资源链接进行域名解析来加以解决。使用 macOS 系统的读者可以参考[《macOS 下三种修改 hosts 文件的方法》]()一文来修改 hosts 文件;使用 Windows 系统的读者可以参考[《在 Windows 上如何管理 hosts 文件》]()一文来进行操作。我们可以把下面的内容添加到 hosts 文件的末尾,这样就可以解决 GitHub 上图片无法显示的问题。 @@ -12,4 +12,6 @@ 151.101.184.133 camo.githubusercontent.com ``` -20933D073D8CCF2D8537AD1E666330E4 +下面是我创建的学习交流群(群号:789050736),欢迎加入一起学习共同进步。 + + diff --git a/第12课:常用数据结构之字典.md b/第12课:常用数据结构之字典.md index 0abdbda..ba7780b 100644 --- a/第12课:常用数据结构之字典.md +++ b/第12课:常用数据结构之字典.md @@ -128,7 +128,8 @@ print(stu2) # {} key, value = students.popitem() print(key, value) # 1003 {'name': '武则天', 'sex': False, 'age': 20, 'place': '四川广元'} -# setdefault可以向字典中存入新的键值对或返回指定的键对应的值 +# 如果这个键在字典中存在,setdefault返回原来与这个键对应的值 +# 如果这个键在字典中不存在,向字典中添加键值对,返回第二个参数的值,默认为None result = students.setdefault(1005, {'name': '方启鹤', 'sex': True}) print(result) # {'name': '方启鹤', 'sex': True} print(students) # {1001: {...}, 1005: {...}} diff --git a/第15课:函数使用进阶.md b/第15课:函数使用进阶.md index 3c3a3da..8816ae3 100644 --- a/第15课:函数使用进阶.md +++ b/第15课:函数使用进阶.md @@ -1,4 +1,4 @@ -## 第15课:函数使用进阶 +## 第13课:函数使用进阶 前面我们讲到了关于函数的知识,我们还讲到过Python中常用的数据类型,这些类型的变量都可以作为函数的参数或返回值,用好函数还可以让我们做更多的事情。 diff --git a/第24课:用Python读写Excel文件.md b/第24课:用Python读写Excel文件.md deleted file mode 100644 index 4f862cc..0000000 --- a/第24课:用Python读写Excel文件.md +++ /dev/null @@ -1,194 +0,0 @@ -## 第24课:用Python读写Excel文件 - -### Excel简介 - -Excel是Microsoft(微软)为使用Windows和macOS操作系统开发的一款电子表格软件。Excel凭借其直观的界面、出色的计算功能和图表工具,再加上成功的市场营销,一直以来都是最为流行的个人计算机数据处理软件。当然,Excel也有很多竞品,例如Google Sheets、LibreOffice Calc、Numbers等,这些竞品基本上也能够兼容Excel,至少能够读写较新版本的Excel文件,当然这些不是我们讨论的重点。掌握用Python程序操作Excel文件,可以让日常办公自动化的工作更加轻松愉快,而且在很多商业项目中,导入导出Excel文件都是特别常见的功能。 - -Python操作Excel需要三方库的支持,如果要兼容Excel 2007以前的版本,也就是`xls`格式的Excel文件,可以使用三方库`xlrd`和`xlwt`,前者用于读Excel文件,后者用于写Excel文件。如果使用较新版本的Excel,即操作`xlsx`格式的Excel文件,也可以使用`openpyxl`库,当然这个库不仅仅可以操作Excel,还可以操作其他基于Office Open XML的电子表格文件。 - -下面我们以`xlwt`和`xlrd`为例讲解如何读写Excel文件,大家可以先使用下面的命令安装这两个三方库文件。 - -```Bash -pip install xlwt xlrd -i https://pypi.doubanio.com/simple -``` - -### 使用xlwt和xlrd - -#### 读Excel文件 - -例如在当前文件夹下有一个名为“阿里巴巴2017年股票数据.xlsx”的Excel文件,如果想读取并显示该文件的内容,可以通过如下所示的代码来完成。 - -```Python -import xlrd - -# 使用xlrd模块的open_workbook函数打开指定Excel文件并获得Book对象(工作簿) -wb = xlrd.open_workbook('阿里巴巴2017年股票数据.xlsx') -# 通过Book对象的sheet_names方法可以获取所有表单名称 -sheetname = wb.sheet_names()[0] -# 通过指定的表单名称获取Sheet对象(工作表) -sheet = wb.sheet_by_name(sheetname) -# 通过Sheet对象的nrows和ncols属性获取表单的行数和列数 -print(sheet.nrows, sheet.ncols) -for row in range(sheet.nrows): - for col in range(sheet.ncols): - # 通过Sheet对象的cell方法获取指定Cell对象(单元格) - # 通过Cell对象的value属性获取单元格中的值 - value = sheet.cell(row, col).value - # 对除首行外的其他行进行数据格式化处理 - if row > 0: - # 第1列的xldate类型先转成元组再格式化为“年月日”的格式 - if col == 0: - # xldate_as_tuple函数的第二个参数只有0和1两个取值 - # 其中0代表以1900-01-01为基准的日期,1代表以1904-01-01为基准的日期 - value = xlrd.xldate_as_tuple(value, 0) - value = f'{value[0]}年{value[1]:>02d}月{value[2]:>02d}日' - # 其他列的number类型处理成小数点后保留两位有效数字的浮点数 - else: - value = f'{value:.2f}' - print(value, end='\t') - print() -# 获取最后一个单元格的数据类型 -# 0 - 空值,1 - 字符串,2 - 数字,3 - 日期,4 - 布尔,5 - 错误 -last_cell_type = sheet.cell_type(sheet.nrows - 1, sheet.ncols - 1) -print(last_cell_type) -# 获取第一行的值(列表) -print(sheet.row_values(0)) -# 获取指定行指定列范围的数据(列表) -# 第一个参数代表行索引,第二个和第三个参数代表列的开始(含)和结束(不含)索引 -print(sheet.row_slice(3, 0, 5)) -``` - -相信通过上面的代码,大家已经了解到了如何读取一个Excel文件,如果想知道更多关于`xlrd`模块的知识,可以阅读它的[官方文档](https://xlrd.readthedocs.io/en/latest/)。 - -#### 写Excel文件 - -写入Excel文件可以通过`xlwt` 模块的`Workbook`类创建工作簿对象,通过工作簿对象的`add_sheet`方法可以添加工作表,通过工作表对象的`write`方法可以向指定单元格中写入数据,最后通过工作簿对象的`save`方法将工作簿写入到指定的文件或内存中。下面的代码实现了将5个学生3门课程的考试成绩写入Excel文件的操作。 - -```Python -import random - -import xlwt - -student_names = ['关羽', '张飞', '赵云', '马超', '黄忠'] -scores = [[random.randint(40, 100) for _ in range(3)] for _ in range(5)] -# 创建工作簿对象(Workbook) -wb = xlwt.Workbook() -# 创建工作表对象(Worksheet) -sheet = wb.add_sheet('一年级二班') -# 添加表头数据 -titles = ('姓名', '语文', '数学', '英语') -for index, title in enumerate(titles): - sheet.write(0, index, title) -# 将学生姓名和考试成绩写入单元格 -for row in range(len(scores)): - sheet.write(row + 1, 0, student_names[row]) - for col in range(len(scores[row])): - sheet.write(row + 1, col + 1, scores[row][col]) -# 保存Excel工作簿 -wb.save('考试成绩表.xlsx') -``` - -#### 调整单元格样式 - -在写Excel文件时,我们还可以为单元格设置样式,主要包括字体(Font)、对齐方式(Alignment)、边框(Border)和背景(Background)的设置,`xlwt`对这几项设置都封装了对应的类来支持。要设置单元格样式需要首先创建一个`XFStyle`对象,再通过该对象的属性对字体、对齐方式、边框等进行设定,例如在上面的例子中,如果希望将表头单元格的背景色修改为黄色,可以按照如下的方式进行操作。 - -```Python -header_style = xlwt.XFStyle() -pattern = xlwt.Pattern() -pattern.pattern = xlwt.Pattern.SOLID_PATTERN -# 0 - 黑色、1 - 白色、2 - 红色、3 - 绿色、4 - 蓝色、5 - 黄色、6 - 粉色、7 - 青色 -pattern.pattern_fore_colour = 5 -header_style.pattern = pattern -titles = ('姓名', '语文', '数学', '英语') -for index, title in enumerate(titles): - sheet.write(0, index, title, header_style) -``` - -如果希望为表头设置指定的字体,可以使用`Font`类并添加如下所示的代码。 - -```Python -font = xlwt.Font() -# 字体名称 -font.name = '华文楷体' -# 字体大小(20是基准单位,18表示18px) -font.height = 20 * 18 -# 是否使用粗体 -font.bold = True -# 是否使用斜体 -font.italic = False -# 字体颜色 -font.colour_index = 1 -header_style.font = font -``` - -如果希望表头垂直居中对齐,可以使用下面的代码进行设置。 - -```Python -align = xlwt.Alignment() -# 垂直方向的对齐方式 -align.vert = xlwt.Alignment.VERT_CENTER -# 水平方向的对齐方式 -align.horz = xlwt.Alignment.HORZ_CENTER -header_style.alignment = align -``` - -如果希望给表头加上黄色的虚线边框,可以使用下面的代码来设置。 - -```Python -borders = xlwt.Borders() -props = ( - ('top', 'top_colour'), ('right', 'right_colour'), - ('bottom', 'bottom_colour'), ('left', 'left_colour') -) -# 通过循环对四个方向的边框样式及颜色进行设定 -for position, color in props: - setattr(borders, position, xlwt.Borders.DASHED) - setattr(borders, color, 5) -header_style.borders = borders -``` - -如果要调整单元格的宽度(列宽)和表头的高度(行高),可以按照下面的代码进行操作。 - -```Python -# 设置行高为40px -sheet.row(0).set_style(xlwt.easyxf(f'font:height {20 * 40}')) -titles = ('姓名', '语文', '数学', '英语') -for index, title in enumerate(titles): - # 设置列宽为200px - sheet.col(index).width = 20 * 200 - # 设置单元格的数据和样式 - sheet.write(0, index, title, header_style) -``` - -#### 公式计算 - -对于前面打开的“阿里巴巴2017年股票数据.xlsx”文件,如果要统计全年收盘价(Close字段)的平均值以及全年交易量(Volume字段)的总和,可以使用Excel的公式计算即可。我们可以先使用`xlrd`读取Excel文件夹,然后通过一个名为`xlutils`的三方库提供的`copy`函数将读取到的Excel文件转成`Workbook`对象进行写操作,在调用`write`方法时,可以将一个`Formula`对象写入单元格。 - -安装`xlutils`三方库。 - -```Bash -pip install xlutils -i https://pypi.doubanio.com/simple -``` - -实现公式计算的代码如下所示。 - -```Python -import xlrd -import xlwt -from xlutils.copy import copy - -wb_for_read = xlrd.open_workbook('阿里巴巴2017年股票数据.xlsx') -sheet1 = wb_for_read.sheet_by_index(0) -nrows, ncols = sheet1.nrows, sheet1.ncols -wb_for_write = copy(wb_for_read) -sheet2 = wb_for_write.get_sheet(0) -sheet2.write(nrows, 4, xlwt.Formula(f'average(E2:E{nrows})')) -sheet2.write(nrows, 6, xlwt.Formula(f'sum(G2:G{nrows})')) -wb_for_write.save('阿里巴巴2017年股票数据-2.xlsx') -``` - -> **说明**:上面的代码有一些小瑕疵,有兴趣的读者可以自行探索如何解决。 - -### 简单的总结 - -其他操作Excel文件的三方库(如`openpyxl`)大家有兴趣可以自行了解。掌握了Python程序操作Excel的方法,可以解决日常办公中很多繁琐的处理Excel电子表格工作,最常见就是将多个数据格式相同的Excel文件合并到一个文件以及从多个Excel文件或表单中提取指定的数据。当然,如果要对表格数据进行处理,使用Python数据分析神器之一的`pandas`库可能更为方便,因为`pandas`库封装的函数以及`DataFrame`类可以完成大多数数据处理的任务。 diff --git a/第27课:用Python操作PDF文件.md b/第27课:用Python操作PDF文件.md index dc41e67..4177f54 100644 --- a/第27课:用Python操作PDF文件.md +++ b/第27课:用Python操作PDF文件.md @@ -20,7 +20,7 @@ page = reader.getPage(0) print(page.extractText()) ``` -> **提示**:上面代码中使用的PDF文件“test.pdf”以及下面的代码中需要用到的PDF文件,都可以通过后面的百度云盘地址进行获取。链接:https://pan.baidu.com/s/1rQujl5RQn9R7PadB2Z5g_g 提取码:e7b4。 +> **提示**:上面代码中使用的PDF文件“test.pdf”以及下面的代码中需要用到的PDF文件,也可以通过下面的百度云盘地址进行获取。链接:https://pan.baidu.com/s/1rQujl5RQn9R7PadB2Z5g_g 提取码:e7b4。 当然,`PyPDF2`并不是什么样的PDF文档都能提取出文字来,这个问题就我所知并没有什么特别好的解决方法,尤其是在提取中文的时候。网上也有很多讲解从PDF中提取文字的文章,推荐大家自行阅读[《三大神器助力Python提取pdf文档信息》](https://cloud.tencent.com/developer/article/1395339)一文进行了解。 @@ -151,7 +151,7 @@ pdf_canvas.save() 上面的代码如果不太理解也没有关系,等真正需要用Python创建PDF文档的时候,再好好研读一下`reportlab`的[官方文档](https://www.reportlab.com/docs/reportlab-userguide.pdf)就可以了。 -> **提示**:上面代码中用到的图片和字体,可以在后面的百度云盘链接中获取。链接:https://pan.baidu.com/s/1rQujl5RQn9R7PadB2Z5g_g 提取码:e7b4。 +> **提示**:上面代码中用到的图片和字体,也可以通过下面的百度云盘链接获取。链接:https://pan.baidu.com/s/1rQujl5RQn9R7PadB2Z5g_g 提取码:e7b4。 ### 简单的总结 diff --git a/第29课:用Python发送邮件和短信.md b/第29课:用Python发送邮件和短信.md index f1c8e85..b2ec68d 100644 --- a/第29课:用Python发送邮件和短信.md +++ b/第29课:用Python发送邮件和短信.md @@ -32,6 +32,7 @@ email['Subject'] = Header('上半年工作情况汇报', 'utf-8') content = """据德国媒体报道,当地时间9日,德国火车司机工会成员进行了投票, 定于当地时间10日起进行全国性罢工,货运交通方面的罢工已于当地时间10日19时开始。 此后,从11日凌晨2时到13日凌晨2时,德国全国范围内的客运和铁路基础设施将进行48小时的罢工。""" +email.attach(MIMEText(content, 'plain', 'utf-8')) # 创建SMTP_SSL对象(连接邮件服务器) smtp_obj = smtplib.SMTP_SSL('smtp.126.com', 465) @@ -139,7 +140,7 @@ def send_email(*, from_user, to_users, subject='', content='', filenames=[]): smtp = smtplib.SMTP_SSL(EMAIL_HOST, EMAIL_PORT) smtp.login(EMAIL_USER, EMAIL_AUTH) - smtp.sendmail(from_user, to_users, email.as_string()) + smtp.sendmail(from_user, to_users.split(';'), email.as_string()) ``` ### 发送短信 diff --git a/第31课:网络数据采集概述.md b/第31课:网络数据采集概述.md new file mode 100644 index 0000000..cd4b240 --- /dev/null +++ b/第31课:网络数据采集概述.md @@ -0,0 +1,200 @@ +## 第31课:网络数据采集概述 + +爬虫(crawler)也经常被称为网络蜘蛛(spider),是按照一定的规则自动浏览网站并获取所需信息的机器人程序(自动化脚本代码),被广泛的应用于互联网搜索引擎和数据采集。使用过互联网和浏览器的人都知道,网页中除了供用户阅读的文字信息之外,还包含一些超链接,网络爬虫正是通过网页中的超链接信息,不断获得网络上其它页面的地址,然后持续的进行数据采集。正因如此,网络数据采集的过程就像一个爬虫或者蜘蛛在网络上漫游,所以才被形象的称为爬虫或者网络蜘蛛。 + +### 爬虫的应用领域 + +在理想的状态下,所有 ICP(Internet Content Provider)都应该为自己的网站提供 API 接口来共享它们允许其他程序获取的数据,在这种情况下就根本不需要爬虫程序。国内比较有名的电商平台(如淘宝、京东等)、社交平台(如微博、微信等)等都提供了自己的 API 接口,但是这类 API 接口通常会对可以抓取的数据以及抓取数据的频率进行限制。对于大多数的公司而言,及时的获取行业数据和竞对数据是企业生存的重要环节之一,然而对大部分企业来说,数据都是其与生俱来的短板。在这种情况下,合理的利用爬虫来获取数据并从中提取出有商业价值的信息对这些企业来说就显得至关重要的。 + +爬虫的应用领域其实非常广泛,下面我们列举了其中的一部分,有兴趣的读者可以自行探索相关内容。 + +1. 搜索引擎 +2. 新闻聚合 +3. 社交应用 +4. 舆情监控 +5. 行业数据 + +### 爬虫合法性探讨 + +经常听人说起“爬虫写得好,牢饭吃到饱”,那么编程爬虫程序是否违法呢?关于这个问题,我们可以从以下几个角度进行解读。 + +1. 网络爬虫这个领域目前还属于拓荒阶段,虽然互联网世界已经通过自己的游戏规则建立起了一定的道德规范,即 Robots 协议(全称是“网络爬虫排除标准”),但法律部分还在建立和完善中,也就是说,现在这个领域暂时还是灰色地带。 +2. “法不禁止即为许可”,如果爬虫就像浏览器一样获取的是前端显示的数据(网页上的公开信息)而不是网站后台的私密敏感信息,就不太担心法律法规的约束,因为目前大数据产业链的发展速度远远超过了法律的完善程度。 +3. 在爬取网站的时候,需要限制自己的爬虫遵守 Robots 协议,同时控制网络爬虫程序的抓取数据的速度;在使用数据的时候,必须要尊重网站的知识产权(从Web 2.0时代开始,虽然Web上的数据很多都是由用户提供的,但是网站平台是投入了运营成本的,当用户在注册和发布内容时,平台通常就已经获得了对数据的所有权、使用权和分发权)。如果违反了这些规定,在打官司的时候败诉几率相当高。 +4. 适当的隐匿自己的身份在编写爬虫程序时必要的,而且最好不要被对方举证你的爬虫有破坏别人动产(例如服务器)的行为。 +5. 不要在公网(如代码托管平台)上去开源或者展示你的爬虫代码,这些行为通常会给自己带来不必要的麻烦。 + +#### Robots协议 + +大多数网站都会定义`robots.txt`文件,这是一个君子协议,并不是所有爬虫都必须遵守的游戏规则。下面以淘宝的[`robots.txt`](http://www.taobao.com/robots.txt)文件为例,看看淘宝网对爬虫有哪些限制。 + +``` +User-agent: Baiduspider +Disallow: / + +User-agent: baiduspider +Disallow: / +``` + +通过上面的文件可以看出,淘宝禁止百度爬虫爬取它任何资源,因此当你在百度搜索“淘宝”的时候,搜索结果下方会出现:“由于该网站的`robots.txt`文件存在限制指令(限制搜索引擎抓取),系统无法提供该页面的内容描述”。百度作为一个搜索引擎,至少在表面上遵守了淘宝网的`robots.txt`协议,所以用户不能从百度上搜索到淘宝内部的产品信息。 + +图1. 百度搜索淘宝的结果 + +![](https://gitee.com/jackfrued/mypic/raw/master/20210824004320.png) + +下面是豆瓣网的[`robots.txt`](https://www.douban.com/robots.txt)文件,大家可以自行解读,看看它做出了什么样的限制。 + +``` +User-agent: * +Disallow: /subject_search +Disallow: /amazon_search +Disallow: /search +Disallow: /group/search +Disallow: /event/search +Disallow: /celebrities/search +Disallow: /location/drama/search +Disallow: /forum/ +Disallow: /new_subject +Disallow: /service/iframe +Disallow: /j/ +Disallow: /link2/ +Disallow: /recommend/ +Disallow: /doubanapp/card +Disallow: /update/topic/ +Disallow: /share/ +Allow: /ads.txt +Sitemap: https://www.douban.com/sitemap_index.xml +Sitemap: https://www.douban.com/sitemap_updated_index.xml +# Crawl-delay: 5 + +User-agent: Wandoujia Spider +Disallow: / + +User-agent: Mediapartners-Google +Disallow: /subject_search +Disallow: /amazon_search +Disallow: /search +Disallow: /group/search +Disallow: /event/search +Disallow: /celebrities/search +Disallow: /location/drama/search +Disallow: /j/ +``` + +### 超文本传输协议(HTTP) + +在开始讲解爬虫之前,我们稍微对超文本传输协议(HTTP)做一些回顾,因为我们在网页上看到的内容通常是浏览器执行 HTML (超文本标记语言)得到的结果,而 HTTP 就是传输 HTML 数据的协议。HTTP 和其他很多应用级协议一样是构建在 TCP(传输控制协议)之上的,它利用了 TCP 提供的可靠的传输服务实现了 Web 应用中的数据交换。按照维基百科上的介绍,设计 HTTP 最初的目的是为了提供一种发布和接收 [HTML](https://zh.wikipedia.org/wiki/HTML) 页面的方法,也就是说,这个协议是浏览器和 Web 服务器之间传输的数据的载体。关于 HTTP 的详细信息以及目前的发展状况,大家可以阅读[《HTTP 协议入门》](http://www.ruanyifeng.com/blog/2016/08/http.html)、[《互联网协议入门》](http://www.ruanyifeng.com/blog/2012/05/internet_protocol_suite_part_i.html)、[《图解 HTTPS 协议》](http://www.ruanyifeng.com/blog/2014/09/illustration-ssl.html)等文章进行了解。 + +下图是我在四川省网络通信技术重点实验室工作期间用开源协议分析工具 Ethereal(WireShark 的前身)截取的访问百度首页时的 HTTP 请求和响应的报文(协议数据),由于 Ethereal 截取的是经过网络适配器的数据,因此可以清晰的看到从物理链路层到应用层的协议数据。 + +图2. HTTP请求 + +![http-request](https://gitee.com/jackfrued/mypic/raw/master/20210824003915.png) + +HTTP 请求通常是由请求行、请求头、空行、消息体四个部分构成,如果没有数据发给服务器,消息体就不是必须的部分。请求行中包含了请求方法(GET、POST 等,如下表所示)、资源路径和协议版本;请求头由若干键值对构成,包含了浏览器、编码方式、首选语言、缓存策略等信息;请求头的后面是空行和消息体。 + + + +图3. HTTP响应 + +![http-response](https://gitee.com/jackfrued/mypic/raw/master/20210824234158.png) + +HTTP 响应通常是由响应行、响应头、空行、消息体四个部分构成,其中消息体是服务响应的数据,可能是 HTML 页面,也有可能是JSON或二进制数据等。响应行中包含了协议版本和响应状态码,响应状态码有很多种,常见的如下表所示。 + + + +#### 相关工具 + +下面我们先介绍一些开发爬虫程序的辅助工具,这些工具相信能帮助你事半功倍。 + +1. Chrome Developer Tools:谷歌浏览器内置的开发者工具。该工具最常用的几个功能模块是: + + - 元素(ELements):用于查看或修改 HTML 元素的属性、CSS 属性、监听事件等。CSS 可以即时修改,即时显示,大大方便了开发者调试页面。 + - 控制台(Console):用于执行一次性代码,查看 JavaScript 对象,查看调试日志信息或异常信息。控制台其实就是一个执行 JavaScript 代码的交互式环境。 + - 源代码(Sources):用于查看页面的 HTML 文件源代码、JavaScript 源代码、CSS 源代码,此外最重要的是可以调试 JavaScript 源代码,可以给代码添加断点和单步执行。 + - 网络(Network):用于 HTTP 请求、HTTP 响应以及与网络连接相关的信息。 + - 应用(Application):用于查看浏览器本地存储、后台任务等内容,本地存储主要包括Cookie、Local Storage、Session Storage等。 + + ![chrome-developer-tools](https://gitee.com/jackfrued/mypic/raw/master/20210824004034.png) + +2. Postman:功能强大的网页调试与 RESTful 请求工具。Postman可以帮助我们模拟请求,非常方便的定制我们的请求以及查看服务器的响应。 + + ![postman](https://gitee.com/jackfrued/mypic/raw/master/20210824004048.png) + +3. HTTPie:命令行HTTP客户端。 + + 安装。 + + ```Bash + pip install httpie + ``` + + 使用。 + + ```Bash + http --header http --header https://movie.douban.com/ + + HTTP/1.1 200 OK + Connection: keep-alive + Content-Encoding: gzip + Content-Type: text/html; charset=utf-8 + Date: Tue, 24 Aug 2021 16:48:00 GMT + Keep-Alive: timeout=30 + Server: dae + Set-Cookie: bid=58h4BdKC9lM; Expires=Wed, 24-Aug-22 16:48:00 GMT; Domain=.douban.com; Path=/ + Strict-Transport-Security: max-age=15552000 + Transfer-Encoding: chunked + X-Content-Type-Options: nosniff + X-DOUBAN-NEWBID: 58h4BdKC9lM + ``` + +4. `builtwith`库:识别网站所用技术的工具。 + + 安装。 + + ```Bash + pip install builtwith + ``` + + 使用。 + + ```Python + import ssl + + import builtwith + + ssl._create_default_https_context = ssl._create_unverified_context + print(builtwith.parse('http://www.bootcss.com/')) + ``` + +5. `python-whois`库:查询网站所有者的工具。 + + 安装。 + + ```Bash + pip3 install python-whois + ``` + + 使用。 + + ```Python + import whois + + print(whois.whois('https://www.bootcss.com')) + ``` + +### 爬虫的基本工作流程 + +一个基本的爬虫通常分为数据采集(网页下载)、数据处理(网页解析)和数据存储(将有用的信息持久化)三个部分的内容,当然更为高级的爬虫在数据采集和处理时会使用并发编程或分布式技术,这就需要有调度器(安排线程或进程执行对应的任务)、后台管理程序(监控爬虫的工作状态以及检查数据抓取的结果)等的参与。 + +![crawler-workflow](https://gitee.com/jackfrued/mypic/raw/master/20210824004107.png) + +一般来说,爬虫的工作流程包括以下几个步骤: + +1. 设定抓取目标(种子页面/起始页面)并获取网页。 +2. 当服务器无法访问时,按照指定的重试次数尝试重新下载页面。 +3. 在需要的时候设置用户代理或隐藏真实IP,否则可能无法访问页面。 +4. 对获取的页面进行必要的解码操作然后抓取出需要的信息。 +5. 在获取的页面中通过某种方式(如正则表达式)抽取出页面中的链接信息。 +6. 对链接进行进一步的处理(获取页面并重复上面的动作)。 +7. 将有用的信息进行持久化以备后续的处理。 diff --git a/第32课:用Python获取网络资源.md b/第32课:用Python获取网络资源.md new file mode 100644 index 0000000..ab7ede2 --- /dev/null +++ b/第32课:用Python获取网络资源.md @@ -0,0 +1,133 @@ +## 第32课:用Python获取网络数据 + +网络数据采集是 Python 语言非常擅长的领域,上节课我们讲到,实现网络数据采集的程序通常称之为网络爬虫或蜘蛛程序。即便是在大数据时代,数据对于中小企业来说仍然是硬伤和短板,有些数据需要通过开放或付费的数据接口来获得,其他的行业数据和竞对数据则必须要通过网络数据采集的方式来获得。不管使用哪种方式获取网络数据资源,Python 语言都是非常好的选择,因为 Python 的标准库和三方库都对网络数据采集提供了良好的支持。 + +### requests库 + +要使用 Python 获取网络数据,我们推荐大家使用名为`requests` 的三方库,这个库我们在之前的课程中其实已经使用过了。按照官方网站的解释,`requests`是基于 Python 标准库进行了封装,简化了通过 HTTP 或 HTTPS 访问网络资源的操作。上课我们提到过,HTTP 是一个请求响应式的协议,当我们在浏览器中输入正确的 [URL](https://developer.mozilla.org/zh-CN/docs/Learn/Common_questions/What_is_a_URL)(通常也称为网址)并按下 Enter 键时,我们就向网络上的 [Web 服务器](https://developer.mozilla.org/zh-CN/docs/Learn/Common_questions/What_is_a_web_server)发送了一个 HTTP 请求,服务器在收到请求后会给我们一个 HTTP 响应。在 Chrome 浏览器中的菜单中打开“开发者工具”切换到“Network”选项卡就能够查看 HTTP 请求和响应到底是什么样子的,如下图所示。 + +![](https://gitee.com/jackfrued/mypic/raw/master/20210822093434.png) + +通过`requests`库,我们可以让 Python 程序向浏览器一样向 Web 服务器发起请求,并接收服务器返回的响应,从响应中我们就可以提取出想要的数据。浏览器呈现给我们的网页是用 [HTML](https://developer.mozilla.org/zh-CN/docs/Web/HTML) 编写的,浏览器相当于是 HTML 的解释器环境,我们看到的网页中的内容都包含在 HTML 的标签中。在获取到 HTML 代码后,就可以从标签的属性或标签体中提取内容。下面例子演示了如何获取网页 HTML 代码,我们通过`requests`库的`get`函数,获取了搜狐首页的代码。 + +```Python +import requests + +resp = requests.get('https://www.sohu.com/') +if resp.status_code == 200: + print(resp.text) +``` + +> **说明**:上面代码中的变量`resp`是一个`Response`对象(`requests`库封装的类型),通过该对象的`status_code`属性可以获取响应状态码,而该对象的`text`属性可以帮我们获取到页面的 HTML 代码。 + +由于`Response`对象的`text`是一个字符串,所以我们可以利用之前讲过的正则表达式的知识,从页面的 HTML 代码中提取新闻的标题和链接,代码如下所示。 + +```Python +import re + +import requests + +pattern = re.compile(r'') +resp = requests.get('https://www.sohu.com/') +if resp.status_code == 200: + all_matches = pattern.findall(resp.text) + for href, title in all_matches: + print(href) + print(title) +``` + +除了文本内容,我们也可以使用`requests`库通过 URL 获取二进制资源。下面的例子演示了如何获取百度 Logo 并保存到名为`baidu.png`的本地文件中。可以在百度的首页上右键点击百度Logo,并通过“复制图片地址”菜单项获取图片的 URL。 + +```Python +import requests + +resp = requests.get('https://www.baidu.com/img/PCtm_d9c8750bed0b3c7d089fa7d55720d6cf.png') +with open('baidu.png', 'wb') as file: + file.write(resp.content) +``` + +> **说明**:`Response`对象的`content`属性可以获得服务器响应的二进制数据。 + +`requests`库非常好用而且功能上也比较强大和完整,具体的内容我们在使用的过程中为大家一点点剖析。想解锁关于`requests`库更多的知识,可以阅读它的[官方文档](https://docs.python-requests.org/zh_CN/latest/)。 + +### 编写爬虫代码 + +接下来,我们以“豆瓣电影”为例,为大家讲解如何编写爬虫代码。按照上面提供的方法,我们先使用`requests`获取到网页的HTML代码,然后将整个代码看成一个长字符串,这样我们就可以使用正则表达式的捕获组从字符串提取我们需要的内容。下面的代码演示了如何从[豆瓣电影](https://movie.douban.com/)获取排前250名的电影的名称。[豆瓣电影Top250](https://movie.douban.com/top250)的页面结构和对应代码如下图所示,可以看出,每页共展示了25部电影,如果要获取到 Top250 数据,我们共需要访问10个页面,对应的地址是,这里的`xxx`如果为`0`就是第一页,如果`xxx`的值是`100`,那么我们可以访问到第五页。为了代码简单易读,我们只获取电影的标题和评分。 + +![](https://gitee.com/jackfrued/mypic/raw/master/20210822093447.png) + +```Python +import random +import re +import time + +import requests + +for page in range(1, 11): + resp = requests.get( + url=f'https://movie.douban.com/top250?start={(page - 1) * 25}', + # 如果不设置HTTP请求头中的User-Agent,豆瓣会检测出不是浏览器而阻止我们的请求。 + # 通过get函数的headers参数设置User-Agent的值,具体的值可以在浏览器的开发者工具查看到。 + # 用爬虫访问大部分网站时,将爬虫伪装成来自浏览器的请求都是非常重要的一步。 + headers={'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36'} + ) + # 通过正则表达式获取class属性为title且标签体不以&开头的span标签并用捕获组提取标签内容 + pattern1 = re.compile(r'([^&]*?)') + titles = pattern1.findall(resp.text) + # 通过正则表达式获取class属性为rating_num的span标签并用捕获组提取标签内容 + pattern2 = re.compile(r'(.*?)') + ranks = pattern2.findall(resp.text) + # 使用zip压缩两个列表,循环遍历所有的电影标题和评分 + for title, rank in zip(titles, ranks): + print(title, rank) + # 随机休眠1-5秒,避免爬取页面过于频繁 + time.sleep(random.random() * 4 + 1) +``` + +> **说明**:通过分析豆瓣网的robots协议,我们发现豆瓣网并不拒绝百度爬虫获取它的数据,因此我们也可以将爬虫伪装成百度的爬虫,将`get`函数的`headers`参数修改为:`headers={'User-Agent': 'BaiduSpider'}`。 + +### 使用 IP 代理 + +让爬虫程序隐匿自己的身份对编写爬虫程序来说是比较重要的,很多网站对爬虫都比较反感的,因为爬虫会耗费掉它们很多的网络带宽并制造很多无效的流量。要隐匿身份通常需要使用**商业 IP 代理**(如蘑菇代理、芝麻代理、快代理等),让被爬取的网站无法获取爬虫程序来源的真实 IP 地址,也就无法简单的通过 IP 地址对爬虫程序进行封禁。 + +下面以[蘑菇代理](http://www.moguproxy.com/)为例,为大家讲解商业 IP 代理的使用方法。首先需要在该网站注册一个账号,注册账号后就可以[购买](http://www.moguproxy.com/buy)相应的套餐来获得商业 IP 代理。作为商业用途,建议大家购买不限量套餐,这样可以根据实际需要获取足够多的代理 IP 地址;作为学习用途,可以购买包时套餐或根据自己的需求来决定。蘑菇代理提供了两种接入代理的方式,分别是 API 私密代理和 HTTP 隧道代理,前者是通过请求蘑菇代理的 API 接口获取代理服务器地址,后者是直接使用统一的入口(蘑菇代理提供的域名)进行接入。 + + + +下面,我们以HTTP隧道代理为例,为大家讲解接入 IP 代理的方式,大家也可以直接参考蘑菇代理官网提供的代码来为爬虫设置代理。 + +```Python +import requests + +APP_KEY = 'Wnp******************************XFx' +PROXY_HOST = 'secondtransfer.moguproxy.com:9001' + +for page in range(1, 11): + resp = requests.get( + url=f'https://movie.douban.com/top250?start={(page - 1) * 25}', + # 需要在HTTP请求头设置代理的身份认证方式 + headers={ + 'Proxy-Authorization': f'Basic {APP_KEY}', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36', + 'Accept-Language': 'zh-CN,zh;q=0.8,en-US;q=0.6,en;q=0.4' + }, + # 设置代理服务器 + proxies={ + 'http': f'http://{PROXY_HOST}', + 'https': f'https://{PROXY_HOST}' + }, + verify=False + ) + pattern1 = re.compile(r'([^&]*?)') + titles = pattern1.findall(resp.text) + pattern2 = re.compile(r'(.*?)') + ranks = pattern2.findall(resp.text) + for title, rank in zip(titles, ranks): + print(title, rank) +``` + +> **说明**:上面的代码需要修改`APP_KEY`为自己创建的订单对应的`Appkey`值,这个值可以在用户中心用户订单中查看到。蘑菇代理提供了免费的 API 代理和 HTTP 隧道代理试用,但是试用的代理接通率不能保证,建议大家还是直接购买一个在自己支付能力范围内的代理服务来体验。 + +### 简单的总结 + +Python 语言能做的事情真的很多,就网络数据采集这一项而言,Python 几乎是一枝独秀的,大量的企业和个人都在使用 Python 从网络上获取自己需要的数据,这可能也是你将来日常工作的一部分。另外,用编写正则表达式的方式从网页中提取内容虽然可行,但是写出一个能够满足需求的正则表达式本身也不是件容易的事情,这一点对于新手来说尤为明显。在下一节课中,我们将会为大家介绍另外两种从页面中提取数据的方法,虽然从性能上来讲,它们可能不如正则表达式,但是却降低了编码的复杂性,相信大家会喜欢上它们的。 diff --git a/第33课:用Python解析HTML页面.md b/第33课:用Python解析HTML页面.md new file mode 100644 index 0000000..b357bcb --- /dev/null +++ b/第33课:用Python解析HTML页面.md @@ -0,0 +1,149 @@ +## 第33课:用Python解析HTML页面 + +在前面的课程中,我们讲到了使用`request`三方库获取网络资源,还介绍了一些前端的基础知识。接下来,我们继续探索如何解析 HTML 代码,从页面中提取出有用的信息。之前,我们尝试过用正则表达式的捕获组操作提取页面内容,但是写出一个正确的正则表达式也是一件让人头疼的事情。为了解决这个问题,我们得先深入的了解一下 HTML 页面的结构,并在此基础上研究另外的解析页面的方法。 + +### HTML 页面的结构 + +我们在浏览器中打开任意一个网站,然后通过鼠标右键菜单,选择“显示网页源代码”菜单项,就可以看到网页对应的 HTML 代码。 + +![image-20210822094218269](https://gitee.com/jackfrued/mypic/raw/master/20210822094218.png) + +代码的第`1`行是文档类型声明,第`2`行的``标签是整个页面根标签的开始标签,最后一行是根标签的结束标签``。``标签下面有两个子标签``和``,放在``标签下的内容会显示在浏览器窗口中,这部分内容是网页的主体;放在``标签下的内容不会显示在浏览器窗口中,但是却包含了页面重要的元信息,通常称之为网页的头部。HTML 页面大致的代码结构如下所示。 + +```HTML + + + + + + + + + +``` + +标签、层叠样式表(CSS)、JavaScript 是构成 HTML 页面的三要素,其中标签用来承载页面要显示的内容,CSS 负责对页面的渲染,而 JavaScript 用来控制页面的交互式行为。要实现 HTML 页面的解析,可以使用 XPath 的语法,它原本是 XML 的一种查询语法,可以根据 HTML 标签的层次结构提取标签中的内容或标签属性;此外,也可以使用 CSS 选择器来定位页面元素,就跟用 CSS 渲染页面元素是同样的道理。 + +### XPath 解析 + +XPath 是在 XML(eXtensible Markup Language)文档中查找信息的一种语法,XML 跟 HTML 类似也是一种用标签承载数据的标签语言,不同之处在于 XML 的标签是可扩展的,可以自定义的,而且 XML 对语法有更严格的要求。XPath 使用路径表达式来选取 XML 文档中的节点或者节点集,这里所说的节点包括元素、属性、文本、命名空间、处理指令、注释、根节点等。下面我们通过一个例子来说明如何使用 XPath 对页面进行解析。 + +```XML + + + + Harry Potter + 29.99 + + + Learning XML + 39.95 + + +``` + +对于上面的 XML 文件,我们可以用如下所示的 XPath 语法获取文档中的节点。 + +| 路径表达式 | 结果 | +| --------------- | ------------------------------------------------------------ | +| `/bookstore` | 选取根元素 bookstore。**注意**:假如路径起始于正斜杠( / ),则此路径始终代表到某元素的绝对路径! | +| `//book` | 选取所有 book 子元素,而不管它们在文档中的位置。 | +| `//@lang` | 选取名为 lang 的所有属性。 | +| `/bookstore/book[1]` | 选取属于 bookstore 子元素的第一个 book 元素。 | +| `/bookstore/book[last()]` | 选取属于 bookstore 子元素的最后一个 book 元素。 | +| `/bookstore/book[last()-1]` | 选取属于 bookstore 子元素的倒数第二个 book 元素。 | +| `/bookstore/book[position()<3]` | 选取最前面的两个属于 bookstore 元素的子元素的 book 元素。 | +| `//title[@lang]` | 选取所有拥有名为 lang 的属性的 title 元素。 | +| `//title[@lang='eng']` | 选取所有 title 元素,且这些元素拥有值为 eng 的 lang 属性。 | +| `/bookstore/book[price>35.00]` | 选取 bookstore 元素的所有 book 元素,且其中的 price 元素的值须大于 35.00。 | +| `/bookstore/book[price>35.00]/title` | 选取 bookstore 元素中的 book 元素的所有 title 元素,且其中的 price 元素的值须大于 35.00。 | + +XPath还支持通配符用法,如下所示。 + +| 路径表达式 | 结果 | +| -------------- | --------------------------------- | +| `/bookstore/*` | 选取 bookstore 元素的所有子元素。 | +| `//*` | 选取文档中的所有元素。 | +| `//title[@*]` | 选取所有带有属性的 title 元素。 | + +如果要选取多个节点,可以使用如下所示的方法。 + +| 路径表达式 | 结果 | +| ---------------------------------- | ------------------------------------------------------------ | +| `//book/title \| //book/price` | 选取 book 元素的所有 title 和 price 元素。 | +| `//title \| //price` | 选取文档中的所有 title 和 price 元素。 | +| `/bookstore/book/title \| //price` | 选取属于 bookstore 元素的 book 元素的所有 title 元素,以及文档中所有的 price 元素。 | + +> **说明**:上面的例子来自于“菜鸟教程”网站上的 [XPath 教程](),有兴趣的读者可以自行阅读原文。 + +当然,如果不理解或不熟悉 XPath 语法,可以在浏览器的开发者工具中按照如下所示的方法查看元素的 XPath 语法,下图是在 Chrome 浏览器的开发者工具中查看豆瓣网电影详情信息中影片标题的 XPath 语法。 + +![](https://gitee.com/jackfrued/mypic/raw/master/20210822093707.png) + +实现 XPath 解析需要三方库`lxml` 的支持,可以使用下面的命令安装`lxml`。 + +```Bash +pip install lxml +``` + +下面我们用 XPath 解析方式改写之前获取豆瓣电影 Top250的代码,如下所示。 + +```Python +from lxml import etree +import requests + +for page in range(1, 11): + resp = requests.get( + url=f'https://movie.douban.com/top250?start={(page - 1) * 25}', + headers={'User-Agent': 'BaiduSpider'} + ) + tree = etree.HTML(resp.text) + # 通过XPath语法从页面中提取电影标题 + title_spans = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[1]/a/span[1]') + # 通过XPath语法从页面中提取电影评分 + rank_spans = tree.xpath('//*[@id="content"]/div/div[1]/ol/li[1]/div/div[2]/div[2]/div/span[2]') + for title_span, rank_span in zip(title_spans, rank_spans): + print(title_span.text, rank_span.text) +``` + +### CSS 选择器解析 + +对于熟悉 CSS 选择器和 JavaScript 的开发者来说,通过 CSS 选择器获取页面元素可能是更为简单的选择,因为浏览器中运行的 JavaScript 本身就可以`document`对象的`querySelector()`和`querySelectorAll()`方法基于 CSS 选择器获取页面元素。在 Python 中,我们可以利用三方库`beautifulsoup4`或`pyquery`来做同样的事情。Beautiful Soup 可以用来解析 HTML 和 XML 文档,修复含有未闭合标签等错误的文档,通过为待解析的页面在内存中创建一棵树结构,实现对从页面中提取数据操作的封装。可以用下面的命令来安装 Beautiful Soup。 + +```Python +pip install beautifulsoup4 +``` + +下面是使用`bs4`改写的获取豆瓣电影Top250电影名称的代码。 + +```Python +import bs4 +import requests + +for page in range(1, 11): + resp = requests.get( + url=f'https://movie.douban.com/top250?start={(page - 1) * 25}', + headers={'User-Agent': 'BaiduSpider'} + ) + # 创建BeautifulSoup对象 + soup = bs4.BeautifulSoup(resp.text, 'lxml') + # 通过CSS选择器从页面中提取包含电影标题的span标签 + title_spans = soup.select('div.info > div.hd > a > span:nth-child(1)') + # 通过CSS选择器从页面中提取包含电影评分的span标签 + rank_spans = soup.select('div.info > div.bd > div > span.rating_num') + for title_span, rank_span in zip(title_spans, rank_spans): + print(title_span.text, rank_span.text) +``` + +关于 BeautifulSoup 更多的知识,可以参考它的[官方文档](https://www.crummy.com/software/BeautifulSoup/bs4/doc.zh/)。 + +### 简单的总结 + +下面我们对三种解析方式做一个简单比较。 + +| 解析方式 | 对应的模块 | 速度 | 使用难度 | +| -------------- | ---------------- | ------ | -------- | +| 正则表达式解析 | `re` | 快 | 困难 | +| XPath 解析 | `lxml` | 快 | 一般 | +| CSS 选择器解析 | `bs4`或`pyquery` | 不确定 | 简单 | + diff --git a/第34课:Python中的并发编程-1.md b/第34课:Python中的并发编程-1.md new file mode 100644 index 0000000..1183e14 --- /dev/null +++ b/第34课:Python中的并发编程-1.md @@ -0,0 +1,382 @@ +## 第34课:Python中的并发编程-1 + +现如今,我们使用的计算机早已是多 CPU 或多核的计算机,而我们使用的操作系统基本都支持“多任务”,这使得我们可以同时运行多个程序,也可以将一个程序分解为若干个相对独立的子任务,让多个子任务“并行”或“并发”的执行,从而缩短程序的执行时间,同时也让用户获得更好的体验。因此当下,不管用什么编程语言进行开发,实现“并行”或“并发”编程已经成为了程序员的标配技能。为了讲述如何在 Python 程序中实现“并行”或“并发”,我们需要先了解两个重要的概念:进程和线程。 + +### 线程和进程 + +我们通过操作系统运行一个程序会创建出一个或多个进程,进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动。简单的说,进程是操作系统分配存储空间的基本单位,每个进程都有自己的地址空间、数据栈以及其他用于跟踪进程执行的辅助数据;操作系统管理所有进程的执行,为它们合理的分配资源。一个进程可以通过 fork 或 spawn 的方式创建新的进程来执行其他的任务,不过新的进程也有自己独立的内存空间,因此两个进程如果要共享数据,必须通过进程间通信机制来实现,具体的方式包括管道、信号、套接字等。 + +一个进程还可以拥有多个执行线索,简单的说就是拥有多个可以获得 CPU 调度的执行单元,这就是所谓的线程。由于线程在同一个进程下,它们可以共享相同的上下文,因此相对于进程而言,线程间的信息共享和通信更加容易。当然在单核 CPU 系统中,多个线程不可能同时执行,因为在某个时刻只有一个线程能够获得 CPU,多个线程通过共享 CPU 执行时间的方式来达到并发的效果。 + +在程序中使用多线程技术通常都会带来不言而喻的好处,最主要的体现在提升程序的性能和改善用户体验,今天我们使用的软件几乎都用到了多线程技术,这一点可以利用系统自带的进程监控工具(如 macOS 中的“活动监视器”、Windows 中的“任务管理器”)来证实,如下图所示。 + + + +这里,我们还需要跟大家再次强调两个概念:**并发**(concurrency)和**并行**(parallel)。**并发**通常是指同一时刻只能有一条指令执行,但是多个线程对应的指令被快速轮换地执行。比如一个处理器,它先执行线程 A 的指令一段时间,再执行线程 B 的指令一段时间,再切回到线程 A 执行一段时间。由于处理器执行指令的速度和切换的速度极快,人们完全感知不到计算机在这个过程中有多个线程切换上下文执行的操作,这就使得宏观上看起来多个线程在同时运行,但微观上其实只有一个线程在执行。**并行**是指同一时刻,有多条指令在多个处理器上同时执行,并行必须要依赖于多个处理器,不论是从宏观上还是微观上,多个线程可以在同一时刻一起执行的。很多时候,我们并不用严格区分并发和并行两个词,所以我们有时候也把 Python 中的多线程、多进程以及异步 I/O 都视为实现并发编程的手段,但实际上前面两者也可以实现并行编程,当然这里还有一个全局解释器锁(GIL)的问题,我们稍后讨论。 + +### 多线程编程 + +Python 标准库中`threading`模块的`Thread`类可以帮助我们非常轻松的实现多线程编程。我们用一个联网下载文件的例子来对比使用多线程和不使用多线程到底有什么区别,代码如下所示。 + +不使用多线程的下载。 + +```Python +import random +import time + + +def download(*, filename): + start = time.time() + print(f'开始下载 {filename}.') + time.sleep(random.randint(3, 6)) + print(f'{filename} 下载完成.') + end = time.time() + print(f'下载耗时: {end - start:.3f}秒.') + + +def main(): + start = time.time() + download(filename='Python从入门到住院.pdf') + download(filename='MySQL从删库到跑路.avi') + download(filename='Linux从精通到放弃.mp4') + end = time.time() + print(f'总耗时: {end - start:.3f}秒.') + + +if __name__ == '__main__': + main() +``` + +> **说明**:上面的代码并没有真正实现联网下载的功能,而是通过`time.sleep()`休眠一段时间来模拟下载文件需要一些时间上的开销,跟实际下载的状况比较类似。 + +运行上面的代码,可以得到如下所示的运行结果。可以看出,当我们的程序只有一个工作线程时,每个下载任务都需要等待上一个下载任务执行结束才能开始,所以程序执行的总耗时是三个下载任务各自执行时间的总和。 + +``` +开始下载Python从入门到住院.pdf. +Python从入门到住院.pdf下载完成. +下载耗时: 3.005秒. +开始下载MySQL从删库到跑路.avi. +MySQL从删库到跑路.avi下载完成. +下载耗时: 5.006秒. +开始下载Linux从精通到放弃.mp4. +Linux从精通到放弃.mp3下载完成. +下载耗时: 6.007秒. +总耗时: 14.018秒. +``` + +事实上,上面的三个下载任务之间并没有逻辑上的因果关系,三者是可以“并发”的,下一个下载任务没有必要等待上一个下载任务结束,为此,我们可以使用多线程编程来改写上面的代码。 + +```Python +import random +import time +from threading import Thread + + +def download(*, filename): + start = time.time() + print(f'开始下载 {filename}.') + time.sleep(random.randint(3, 6)) + print(f'{filename} 下载完成.') + end = time.time() + print(f'下载耗时: {end - start:.3f}秒.') + + +def main(): + threads = [ + Thread(target=download, kwargs={'filename': 'Python从入门到住院.pdf'}), + Thread(target=download, kwargs={'filename': 'MySQL从删库到跑路.avi'}), + Thread(target=download, kwargs={'filename': 'Linux从精通到放弃.mp4'}) + ] + start = time.time() + # 启动三个线程 + for thread in threads: + thread.start() + # 等待线程结束 + for thread in threads: + thread.join() + end = time.time() + print(f'总耗时: {end - start:.3f}秒.') + + +if __name__ == '__main__': + main() +``` + +某次的运行结果如下所示。 + +``` +开始下载 Python从入门到住院.pdf. +开始下载 MySQL从删库到跑路.avi. +开始下载 Linux从精通到放弃.mp4. +MySQL从删库到跑路.avi 下载完成. +下载耗时: 3.005秒. +Python从入门到住院.pdf 下载完成. +下载耗时: 5.006秒. +Linux从精通到放弃.mp4 下载完成. +下载耗时: 6.003秒. +总耗时: 6.004秒. +``` + +通过上面的运行结果可以发现,整个程序的执行时间几乎等于耗时最长的一个下载任务的执行时间,这也就意味着,三个下载任务是并发执行的,不存在一个等待另一个的情况,这样做很显然提高了程序的执行效率。简单的说,如果程序中有非常耗时的执行单元,而这些耗时的执行单元之间又没有逻辑上的因果关系,即 B 单元的执行不依赖于 A 单元的执行结果,那么 A 和 B 两个单元就可以放到两个不同的线程中,让他们并发的执行。这样做的好处除了减少程序执行的等待时间,还可以带来更好的用户体验,因为一个单元的阻塞不会造成程序的“假死”,因为程序中还有其他的单元是可以运转的。 + +#### 使用 Thread 类创建线程对象 + +通过上面的代码可以看出,直接使用`Thread`类的构造器就可以创建线程对象,而线程对象的`start()`方法可以启动一个线程。线程启动后会执行`target`参数指定的函数,当然前提是获得 CPU 的调度;如果`target`指定的线程要执行的目标函数有参数,需要通过`args`参数为其进行指定,对于关键字参数,可以通过`kwargs`参数进行传入。`Thread`类的构造器还有很多其他的参数,我们遇到的时候再为大家进行讲解,目前需要大家掌握的,就是`target`、`args`和`kwargs`。 + +#### 继承 Thread 类自定义线程 + +除了上面的代码展示的创建线程的方式外,还可以通过继承`Thread`类并重写`run()`方法的方式来自定义线程,具体的代码如下所示。 + +```Python +import random +import time +from threading import Thread + + +class DownloadThread(Thread): + + def __init__(self, filename): + self.filename = filename + super().__init__() + + def run(self): + start = time.time() + print(f'开始下载 {self.filename}.') + time.sleep(random.randint(3, 6)) + print(f'{self.filename} 下载完成.') + end = time.time() + print(f'下载耗时: {end - start:.3f}秒.') + + +def main(): + threads = [ + DownloadThread('Python从入门到住院.pdf'), + DownloadThread('MySQL从删库到跑路.avi'), + DownloadThread('Linux从精通到放弃.mp4') + ] + start = time.time() + # 启动三个线程 + for thread in threads: + thread.start() + # 等待线程结束 + for thread in threads: + thread.join() + end = time.time() + print(f'总耗时: {end - start:.3f}秒.') + + +if __name__ == '__main__': + main() +``` + +#### 使用线程池 + +我们还可以通过线程池的方式将任务放到多个线程中去执行,通过线程池来使用线程应该是多线程编程最理想的选择。事实上,线程的创建和释放都会带来较大的开销,频繁的创建和释放线程通常都不是很好的选择。利用线程池,可以提前准备好若干个线程,在使用的过程中不需要再通过自定义的代码创建和释放线程,而是直接复用线程池中的线程。Python 内置的`concurrent.futures`模块提供了对线程池的支持,代码如下所示。 + +```Python +import random +import time +from concurrent.futures import ThreadPoolExecutor +from threading import Thread + + +def download(*, filename): + start = time.time() + print(f'开始下载 {filename}.') + time.sleep(random.randint(3, 6)) + print(f'{filename} 下载完成.') + end = time.time() + print(f'下载耗时: {end - start:.3f}秒.') + + +def main(): + with ThreadPoolExecutor(max_workers=4) as pool: + filenames = ['Python从入门到住院.pdf', 'MySQL从删库到跑路.avi', 'Linux从精通到放弃.mp4'] + start = time.time() + for filename in filenames: + pool.submit(download, filename=filename) + end = time.time() + print(f'总耗时: {end - start:.3f}秒.') + + +if __name__ == '__main__': + main() +``` + +### 守护线程 + +所谓“守护线程”就是在主线程结束的时候,不值得再保留的执行线程。这里的不值得保留指的是守护线程会在其他非守护线程全部运行结束之后被销毁,它守护的是当前进程内所有的非守护线程。简单的说,守护线程会跟随主线程一起挂掉,而主线程的生命周期就是一个进程的生命周期。如果不理解,我们可以看一段简单的代码。 + +```Python +import time +from threading import Thread + + +def display(content): + while True: + print(content, end='', flush=True) + time.sleep(0.1) + + +def main(): + Thread(target=display, args=('Ping', )).start() + Thread(target=display, args=('Pong', )).start() + + +if __name__ == '__main__': + main() +``` + +> **说明**:上面的代码中,我们将`print`函数的参数`flush`设置为`True`,这是因为`flush`参数的值如果为`False`,而`print`又没有做换行处理,就会导致每次`print`输出的内容被放到操作系统的输出缓冲区,直到缓冲区被输出的内容塞满,才会清空缓冲区产生一次输出。上述现象是操作系统为了减少 I/O 中断,提升 CPU 利用率做出的设定,为了让代码产生直观交互,我们才将`flush`参数设置为`True`,强制每次输出都清空输出缓冲区。 + +上面的代码运行起来之后是不会停止的,因为两个子线程中都有死循环,除非你手动中断代码的执行。但是,如果在创建线程对象时,将名为`daemon`的参数设置为`True`,这两个线程就会变成守护线程,那么在其他线程结束时,即便有死循环,两个守护线程也会挂掉,不会再继续执行下去,代码如下所示。 + + ```Python + import time + from threading import Thread + + + def display(content): + while True: + print(content, end='', flush=True) + time.sleep(0.1) + + + def main(): + Thread(target=display, args=('Ping', ), daemon=True).start() + Thread(target=display, args=('Pong', ), daemon=True).start() + time.sleep(5) + + + if __name__ == '__main__': + main() + ``` + +上面的代码,我们在主线程中添加了一行`time.sleep(5)`让主线程休眠5秒,在这个过程中,输出`Ping`和`Pong`的守护线程会持续运转,直到主线程在5秒后结束,这两个守护线程也被销毁,不再继续运行。 + +> **思考**:如果将上面代码第12行的`daemon=True`去掉,代码会怎样执行?有兴趣的读者可以尝试一下,并看看实际执行的结果跟你想象的是否一致。 + +### 资源竞争 + +在编写多线程代码时,不可避免的会遇到多个线程竞争同一个资源(对象)的情况。在这种情况下,如果没有合理的机制来保护被竞争的资源,那么就有可能出现非预期的状况。下面的代码创建了`100`个线程向同一个银行账户(初始余额为`0`元)转账,每个线程转账金额为`1`元。在正常的情况下,我们的银行账户最终的余额应该是`100`元,但是运行下面的代码我们并不能得到`100`元这个结果。 + +```Python +import time + +from concurrent.futures import ThreadPoolExecutor + + +class Account(object): + """银行账户""" + + def __init__(self): + self.balance = 0.0 + + def deposit(self, money): + """存钱""" + new_balance = self.balance + money + time.sleep(0.01) + self.balance = new_balance + + +def main(): + """主函数""" + account = Account() + with ThreadPoolExecutor(max_workers=16) as pool: + for _ in range(100): + pool.submit(account.deposit, 1) + print(account.balance) + + +if __name__ == '__main__': + main() +``` + +上面代码中的`Account`类代表了银行账户,它的`deposit`方法代表存款行为,参数`money`代表存入的金额,该方法通过`time.sleep`函数模拟受理存款需要一段时间。我们通过线程池的方式启动了`100`个线程向一个账户转账,但是上面的代码并不能运行出`100`这个我们期望的结果,这就是在多个线程竞争一个资源的时候,可能会遇到的数据不一致的问题。注意上面代码的第`14`行,当多个线程都执行到这行代码时,它们会在相同的余额上执行加上存入金额的操作,这就会造成“丢失更新”现象,即之前修改数据的成果被后续的修改给覆盖掉了,所以才得不到正确的结果。 + +要解决上面的问题,可以使用锁机制,通过锁对操作数据的关键代码加以保护。Python 标准库的`threading`模块提供了`Lock`和`RLock`类来支持锁机制,这里我们不去深究二者的区别,建议大家直接使用`RLock`。接下来,我们给银行账户添加一个锁对象,通过锁对象来解决刚才存款时发生“丢失更新”的问题,代码如下所示。 + +```Python +import time + +from concurrent.futures import ThreadPoolExecutor +from threading import RLock + + +class Account(object): + """银行账户""" + + def __init__(self): + self.balance = 0.0 + self.lock = RLock() + + def deposit(self, money): + # 获得锁 + self.lock.acquire() + try: + new_balance = self.balance + money + time.sleep(0.01) + self.balance = new_balance + finally: + # 释放锁 + self.lock.release() + + +def main(): + """主函数""" + account = Account() + with ThreadPoolExecutor(max_workers=16) as pool: + for _ in range(100): + pool.submit(account.deposit, 1) + print(account.balance) + + +if __name__ == '__main__': + main() +``` + +上面代码中,获得锁和释放锁的操作也可以通过上下文语法来实现,使用上下文语法会让代码更加简单优雅,这也是我们推荐大家使用的方式。 + +```Python +import time + +from concurrent.futures import ThreadPoolExecutor +from threading import RLock + + +class Account(object): + """银行账户""" + + def __init__(self): + self.balance = 0.0 + self.lock = RLock() + + def deposit(self, money): + # 通过上下文语法获得锁和释放锁 + with self.lock: + new_balance = self.balance + money + time.sleep(0.01) + self.balance = new_balance + + +def main(): + """主函数""" + account = Account() + with ThreadPoolExecutor(max_workers=16) as pool: + for _ in range(100): + pool.submit(account.deposit, 1) + print(account.balance) + + +if __name__ == '__main__': + main() +``` + +> **思考**:将上面的代码修改为5个线程向银行账户存钱,5个线程从银行账户取钱,取钱的线程在银行账户余额不足时,需要停下来等待存钱的线程将钱存入后再尝试取钱。这里需要用到线程调度的知识,大家可以自行研究下`threading`模块中的`Condition`类,看看是否能够完成这个任务。 + +### GIL问题 + +如果使用官方的 Python 解释器(通常称之为 CPython)运行 Python 程序,我们并不能通过使用多线程的方式将 CPU 的利用率提升到逼近400%(对于4核 CPU)或逼近800%(对于8核 CPU)这样的水平,因为 CPython 在执行代码时,会受到 GIL(全局解释器锁)的限制。具体的说,CPython 在执行任何代码时,都需要对应的线程先获得 GIL,然后每执行100条(字节码)指令,CPython 就会让获得 GIL 的线程主动释放 GIL,这样别的线程才有机会执行。因为 GIL 的存在,无论你的 CPU 有多少个核,我们编写的 Python 代码也没有机会真正并行的执行。 + +GIL 是官方 Python 解释器在设计上的历史遗留问题,要解决这个问题,让多线程能够发挥 CPU 的多核优势,需要重新实现一个不带 GIL 的 Python 解释器。这个问题按照官方的说法,在 Python 发布4.0版本时会得到解决,就让我们拭目以待吧。当下,对于 CPython 而言,如果希望充分发挥 CPU 的多核优势,可以考虑使用多进程,因为每个进程都对应一个 Python 解释器,因此每个进程都有自己独立的 GIL,这样就可以突破 GIL 的限制。在下一个章节中,我们会为大家介绍关于多进程的相关知识,并对多线程和多进程的代码及其执行效果进行比较。 + diff --git a/第35课:Python中的并发编程-2.md b/第35课:Python中的并发编程-2.md new file mode 100644 index 0000000..439a9bc --- /dev/null +++ b/第35课:Python中的并发编程-2.md @@ -0,0 +1,254 @@ +## 第35课:Python中的并发编程-2 + +在上一课中我们说过,由于 GIL 的存在,CPython 中的多线程并不能发挥 CPU 的多核优势,如果希望突破 GIL 的限制,可以考虑使用多进程。对于多进程的程序,每个进程都有一个属于自己的 GIL,所以多进程不会受到 GIL 的影响。那么,我们应该如何在 Python 程序中创建和使用多进程呢? + +###创建进程 + +在 Python 中可以基于`Process`类来创建进程,虽然进程和线程有着本质的差别,但是`Process`类和`Thread`类的用法却非常类似。在使用`Process`类的构造器创建对象时,也是通过`target`参数传入一个函数来指定进程要执行的代码,而`args`和`kwargs`参数可以指定该函数使用的参数值。 + +```Python +from multiprocessing import Process, current_process +from time import sleep + + +def sub_task(content, nums): + # 通过current_process函数获取当前进程对象 + # 通过进程对象的pid和name属性获取进程的ID号和名字 + print(f'PID: {current_process().pid}') + print(f'Name: {current_process().name}') + # 通过下面的输出不难发现,每个进程都有自己的nums列表,进程之间本就不共享内存 + # 在创建子进程时复制了父进程的数据结构,三个进程从列表中pop(0)得到的值都是20 + counter, total = 0, nums.pop(0) + print(f'Loop count: {total}') + sleep(0.5) + while counter < total: + counter += 1 + print(f'{counter}: {content}') + sleep(0.01) + + +def main(): + nums = [20, 30, 40] + # 创建并启动进程来执行指定的函数 + Process(target=sub_task, args=('Ping', nums)).start() + Process(target=sub_task, args=('Pong', nums)).start() + # 在主进程中执行sub_task函数 + sub_task('Good', nums) + + +if __name__ == '__main__': + main() +``` + +> **说明**:上面的代码通过`current_process`函数获取当前进程对象,再通过进程对象的`pid`属性获取进程ID。在 Python 中,使用`os`模块的`getpid`函数也可以达到同样的效果。 + +如果愿意,也可以使用`os`模块的`fork`函数来创建进程,调用该函数时,操作系统自动把当前进程(父进程)复制一份(子进程),父进程的`fork`函数会返回子进程的ID,而子进程中的`fork`函数会返回`0`,也就是说这个函数调用一次会在父进程和子进程中得到两个不同的返回值。需要注意的是,Windows 系统并不支持`fork`函数,如果你使用的是 Linux 或 macOS 系统,可以试试下面的代码。 + +```Python +import os + +print(f'PID: {os.getpid()}') +pid = os.fork() +if pid == 0: + print(f'子进程 - PID: {os.getpid()}') + print('Todo: 在子进程中执行的代码') +else: + print(f'父进程 - PID: {os.getpid()}') + print('Todo: 在父进程中执行的代码') +``` + +简而言之,我们还是推荐大家通过直接使用`Process`类、继承`Process`类和使用进程池(`ProcessPoolExecutor`)这三种方式来创建和使用多进程,这三种方式不同于上面的`fork`函数,能够保证代码的兼容性和可移植性。具体的做法跟之前讲过的创建和使用多线程的方式比较接近,此处不再进行赘述。 + +### 多进程和多线程的比较 + +对于爬虫这类 I/O 密集型任务来说,使用多进程并没有什么优势;但是对于计算密集型任务来说,多进程相比多线程,在效率上会有显著的提升,我们可以通过下面的代码来加以证明。下面的代码会通过多线程和多进程两种方式来判断一组大整数是不是质数,很显然这是一个计算密集型任务,我们将任务分别放到多个线程和多个进程中来加速代码的执行,让我们看看多线程和多进程的代码具体表现有何不同。 + +我们先实现一个多线程的版本,代码如下所示。 + +```Python +import concurrent.futures + +PRIMES = [ + 1116281, + 1297337, + 104395303, + 472882027, + 533000389, + 817504243, + 982451653, + 112272535095293, + 112582705942171, + 112272535095293, + 115280095190773, + 115797848077099, + 1099726899285419 +] * 5 + + +def is_prime(n): + """判断素数""" + for i in range(2, int(n ** 0.5) + 1): + if n % i == 0: + return False + return n != 1 + + +def main(): + """主函数""" + with concurrent.futures.ThreadPoolExecutor(max_workers=16) as executor: + for number, prime in zip(PRIMES, executor.map(is_prime, PRIMES)): + print('%d is prime: %s' % (number, prime)) + + +if __name__ == '__main__': + main() +``` + +假设上面的代码保存在名为`example.py`的文件中,在 Linux 或 macOS 系统上,可以使用`time python example.py`命令执行程序并获得操作系统关于执行时间的统计,在我的 macOS 上,某次的运行结果的最后一行输出如下所示。 + +``` +python example09.py 38.69s user 1.01s system 101% cpu 39.213 total +``` + +从运行结果可以看出,多线程的代码只能让 CPU 利用率达到100%,这其实已经证明了多线程的代码无法利用 CPU 多核特性来加速代码的执行,我们再看看多进程的版本,我们将上面代码中的线程池(`ThreadPoolExecutor`)更换为进程池(`ProcessPoolExecutor`)。 + +多进程的版本。 + +```Python +import concurrent.futures + +PRIMES = [ + 1116281, + 1297337, + 104395303, + 472882027, + 533000389, + 817504243, + 982451653, + 112272535095293, + 112582705942171, + 112272535095293, + 115280095190773, + 115797848077099, + 1099726899285419 +] * 5 + + +def is_prime(n): + """判断素数""" + for i in range(2, int(n ** 0.5) + 1): + if n % i == 0: + return False + return n != 1 + + +def main(): + """主函数""" + with concurrent.futures.ProcessPoolExecutor(max_workers=16) as executor: + for number, prime in zip(PRIMES, executor.map(is_prime, PRIMES)): + print('%d is prime: %s' % (number, prime)) + + +if __name__ == '__main__': + main() +``` + +> **提示**:运行上面的代码时,可以通过操作系统的任务管理器(资源监视器)来查看是否启动了多个 Python 解释器进程。 + +我们仍然通过`time python example.py`的方式来执行上述代码,运行结果的最后一行如下所示。 + +``` +python example09.py 106.63s user 0.57s system 389% cpu 27.497 total +``` + +可以看出,多进程的版本在我使用的这台电脑上,让 CPU 的利用率达到了将近400%,而运行代码时用户态耗费的 CPU 的时间(106.63秒)几乎是代码运行总时间(27.497秒)的4倍,从这两点都可以看出,我的电脑使用了一款4核的 CPU。当然,要知道自己的电脑有几个 CPU 或几个核,可以直接使用下面的代码。 + +```Python +import os + +print(os.cpu_count()) +``` + +综上所述,多进程可以突破 GIL 的限制,充分利用 CPU 多核特性,对于计算密集型任务,这一点是相当重要的。常见的计算密集型任务包括科学计算、图像处理、音视频编解码等,如果这些计算密集型任务本身是可以并行的,那么使用多进程应该是更好的选择。 + +### 进程间通信 + +在讲解进程间通信之前,先给大家一个任务:启动两个进程,一个输出“Ping”,一个输出“Pong”,两个进程输出的“Ping”和“Pong”加起来一共有50个时,就结束程序。听起来是不是非常简单,但是实际编写代码时,由于多个进程之间不能够像多个线程之间直接通过共享内存的方式交换数据,所以下面的代码是达不到我们想要的结果的。 + +```Python +from multiprocessing import Process +from time import sleep + +counter = 0 + + +def sub_task(string): + global counter + while counter < 50: + print(string, end='', flush=True) + counter += 1 + sleep(0.01) + + +def main(): + Process(target=sub_task, args=('Ping', )).start() + Process(target=sub_task, args=('Pong', )).start() + + +if __name__ == '__main__': + main() +``` + +上面的代码看起来没毛病,但是最后的结果是“Ping”和“Pong”各输出了50个。再次提醒大家,当我们在程序中创建进程的时候,子进程会复制父进程及其所有的数据结构,每个子进程有自己独立的内存空间,这也就意味着两个子进程中各有一个`counter`变量,它们都会从`0`加到`50`,所以结果就可想而知了。要解决这个问题比较简单的办法是使用`multiprocessing`模块中的`Queue`类,它是可以被多个进程共享的队列,底层是通过操作系统底层的管道和信号量(semaphore)机制来实现的,代码如下所示。 + +```Python +import time +from multiprocessing import Process, Queue + + +def sub_task(content, queue): + counter = queue.get() + while counter < 50: + print(content, end='', flush=True) + counter += 1 + queue.put(counter) + time.sleep(0.01) + counter = queue.get() + + +def main(): + queue = Queue() + queue.put(0) + p1 = Process(target=sub_task, args=('Ping', queue)) + p1.start() + p2 = Process(target=sub_task, args=('Pong', queue)) + p2.start() + while p1.is_alive() and p2.is_alive(): + pass + queue.put(50) + + +if __name__ == '__main__': + main() +``` + +> **提示**:`multiprocessing.Queue`对象的`get`方法默认在队列为空时是会阻塞的,直到获取到数据才会返回。如果不希望该方法阻塞以及需要指定阻塞的超时时间,可以通过指定`block`和`timeout`参数进行设定。 + +上面的代码通过`Queue`类的`get`和`put`方法让三个进程(`p1`、`p2`和主进程)实现了数据的共享,这就是所谓的进程间的通信,通过这种方式,当`Queue`中取出的值已经大于等于`50`时,`p1`和`p2`就会跳出`while`循环,从而终止进程的执行。代码第22行的循环是为了等待`p1`和`p2`两个进程中的一个结束,这时候主进程还需要向`Queue`中放置一个大于等于`50`的值,这样另一个尚未结束的进程也会因为读到这个大于等于`50`的值而终止。 + +进程间通信的方式还有很多,比如使用套接字也可以实现两个进程的通信,甚至于这两个进程并不在同一台主机上,有兴趣的读者可以自行了解。 + +### 简单的总结 + +在 Python 中,我们还可以通过`subprocess`模块的`call`函数执行其他的命令来创建子进程,相当于就是在我们的程序中调用其他程序,这里我们暂不探讨这些知识,有兴趣的读者可以自行研究。 + +对于Python开发者来说,以下情况需要考虑使用多线程: + +1. 程序需要维护许多共享的状态(尤其是可变状态),Python 中的列表、字典、集合都是线程安全的(多个线程同时操作同一个列表、字典或集合,不会引发错误和数据问题),所以使用线程而不是进程维护共享状态的代价相对较小。 +2. 程序会花费大量时间在 I/O 操作上,没有太多并行计算的需求且不需占用太多的内存。 + +那么在遇到下列情况时,应该考虑使用多进程: + +1. 程序执行计算密集型任务(如:音视频编解码、数据压缩、科学计算等)。 +2. 程序的输入可以并行的分成块,并且可以将运算结果合并。 +3. 程序在内存使用方面没有任何限制且不强依赖于 I/O 操作(如读写文件、套接字等)。 diff --git a/第36课:Python中的并发编程-3.md b/第36课:Python中的并发编程-3.md new file mode 100644 index 0000000..f7fcfe2 --- /dev/null +++ b/第36课:Python中的并发编程-3.md @@ -0,0 +1,215 @@ +## 第36课:Python中的并发编程-3 + +爬虫是典型的 I/O 密集型任务,I/O 密集型任务的特点就是程序会经常性的因为 I/O 操作而进入阻塞状态,比如我们之前使用`requests`获取页面代码或二进制内容,发出一个请求之后,程序必须要等待网站返回响应之后才能继续运行,如果目标网站不是很给力或者网络状况不是很理想,那么等待响应的时间可能会很久,而在这个过程中整个程序是一直阻塞在那里,没有做任何的事情。通过前面的课程,我们已经知道了可以通过多线程的方式为爬虫提速,使用多线程的本质就是,当一个线程阻塞的时候,程序还有其他的线程可以继续运转,因此整个程序就不会在阻塞和等待中浪费了大量的时间。 + +事实上,还有一种非常适合 I/O 密集型任务的并发编程方式,我们称之为异步编程,你也可以将它称为异步 I/O。这种方式并不需要启动多个线程或多个进程来实现并发,它是通过多个子程序相互协作的方式来提升 CPU 的利用率,解决了 I/O 密集型任务 CPU 利用率很低的问题,我一般将这种方式称为“协作式并发”。这里,我不打算探讨操作系统的各种 I/O 模式,因为这对很多读者来说都太过抽象;但是我们得先抛出两组概念给大家,一组叫做“阻塞”和“非阻塞”,一组叫做“同步”和“异步”。 + +### 基本概念 + +#### 阻塞 + +阻塞状态指程序未得到所需计算资源时被挂起的状态。程序在等待某个操作完成期间,自身无法继续处理其他的事情,则称该程序在该操作上是阻塞的。阻塞随时都可能发生,最典型的就是 I/O 中断(包括网络 I/O 、磁盘 I/O 、用户输入等)、休眠操作、等待某个线程执行结束,甚至包括在 CPU 切换上下文时,程序都无法真正的执行,这就是所谓的阻塞。 + +#### 非阻塞 + +程序在等待某操作过程中,自身不被阻塞,可以继续处理其他的事情,则称该程序在该操作上是非阻塞的。非阻塞并不是在任何程序级别、任何情况下都可以存在的。仅当程序封装的级别可以囊括独立的子程序单元时,它才可能存在非阻塞状态。显然,某个操作的阻塞可能会导程序耗时以及效率低下,所以我们会希望把它变成非阻塞的。 + +#### 同步 + +不同程序单元为了完成某个任务,在执行过程中需靠某种通信方式以协调一致,我们称这些程序单元是同步执行的。例如前面讲过的给银行账户存钱的操作,我们在代码中使用了“锁”作为通信信号,让多个存钱操作强制排队顺序执行,这就是所谓的同步。 + +#### 异步 + +不同程序单元在执行过程中无需通信协调,也能够完成一个任务,这种方式我们就称之为异步。例如,使用爬虫下载页面时,调度程序调用下载程序后,即可调度其他任务,而无需与该下载任务保持通信以协调行为。不同网页的下载、保存等操作都是不相关的,也无需相互通知协调。很显然,异步操作的完成时刻和先后顺序并不能确定。 + +很多人都不太能准确的把握这几个概念,这里我们简单的总结一下,同步与异步的关注点是**消息通信机制**,最终表现出来的是“有序”和“无序”的区别;阻塞和非阻塞的关注点是**程序在等待消息时状态**,最终表现出来的是程序在等待时能不能做点别的。如果想深入理解这些内容,推荐大家阅读经典著作[《UNIX网络编程》](https://item.jd.com/11880047.html),这本书非常的赞。 + +### 生成器和协程 + +前面我们说过,异步编程是一种“协作式并发”,即通过多个子程序相互协作的方式提升 CPU 的利用率,从而减少程序在阻塞和等待中浪费的时间,最终达到并发的效果。我们可以将多个相互协作的子程序称为“协程”,它是实现异步编程的关键。在介绍协程之前,我们先通过下面的代码,看看什么是生成器。 + +```Python +def fib(max_count): + a, b = 0, 1 + for _ in range(max_count): + a, b = b, a + b + yield a +``` + +上面我们编写了一个生成斐波那契数列的生成器,调用上面的`fib`函数并不是执行该函数获得返回值,因为`fib`函数中有一个特殊的关键字`yield`。这个关键字使得`fib`函数跟普通的函数有些区别,调用该函数会得到一个生成器对象,我们可以通过下面的代码来验证这一点。 + +```Python +gen_obj = fib(20) +print(gen_obj) +``` + +输出: + +``` + +``` + +我们可以使用内置函数`next`从生成器对象中获取斐波那契数列的值,也可以通过`for-in`循环对生成器能够提供的值进行遍历,代码如下所示。 + +```Python +for value in gen_obj: + print(value) +``` + +生成器经过预激活,就是一个协程,它可以跟其他子程序协作。 + +```Python +def calc_average(): + total, counter = 0, 0 + avg_value = None + while True: + curr_value = yield avg_value + total += curr_value + counter += 1 + avg_value = total / counter + + +def main(): + obj = calc_average() + # 生成器预激活 + obj.send(None) + for _ in range(5): + print(obj.send(float(input()))) + + +if __name__ == '__main__': + main() +``` + +上面的`main`函数首先通过生成器对象的`send`方法发送一个`None`值来将其激活为协程,也可以通过`next(obj)`达到同样的效果。接下来,协程对象会接收`main`函数发送的数据并产出(`yield`)数据的平均值。通过上面的例子,不知道大家是否看出两段子程序是怎么“协作”的。 + +### 异步函数 + +Python 3.5版本中,引入了两个非常有意思的元素,一个叫`async`,一个叫`await`,它们在Python 3.7版本中成为了正式的关键字。通过这两个关键字,可以简化协程代码的编写,可以用更为简单的方式让多个子程序很好的协作起来。我们通过一个例子来加以说明,请大家先看看下面的代码。 + +```Python +import time + + +def display(num): + time.sleep(1) + print(num) + + +def main(): + start = time.time() + for i in range(1, 10): + display(i) + end = time.time() + print(f'{end - start:.3f}秒') + + +if __name__ == '__main__': + main() +``` + +上面的代码每次执行都会依次输出`1`到`9`的数字,每个间隔`1`秒钟,整个代码需要执行大概需要`9`秒多的时间,这一点我相信大家都能看懂。不知道大家是否意识到,这段代码就是以同步和阻塞的方式执行的,同步可以从代码的输出看出来,而阻塞是指在调用`display`函数发生休眠时,整个代码的其他部分都不能继续执行,必须等待休眠结束。 + +接下来,我们尝试用异步的方式改写上面的代码,让`display`函数以异步的方式运转。 + +```Python +import asyncio +import time + + +async def display(num): + await asyncio.sleep(1) + print(num) + + +def main(): + start = time.time() + objs = [display(i) for i in range(1, 10)] + loop = asyncio.get_event_loop() + loop.run_until_complete(asyncio.wait(objs)) + loop.close() + end = time.time() + print(f'{end - start:.3f}秒') + + +if __name__ == '__main__': + main() +``` + +Python 中的`asyncio`模块提供了对异步 I/O 的支持。上面的代码中,我们首先在`display`函数前面加上了`async`关键字使其变成一个异步函数,调用异步函数不会执行函数体而是获得一个协程对象。我们将`display`函数中的`time.sleep(1)`修改为`await asyncio.sleep(1)`,二者的区别在于,后者不会让整个代码陷入阻塞,因为`await`操作会让其他协作的子程序有获得 CPU 资源而得以运转的机会。为了让这些子程序可以协作起来,我们需要将他们放到一个事件循环(实现消息分派传递的系统)上,因为**当协程遭遇 I/O 操作阻塞时,就会到事件循环中监听 I/O 操作是否完成,并注册自身的上下文以及自身的唤醒函数(以便恢复执行),之后该协程就变为阻塞状态**。上面的第12行代码创建了`9`个协程对象并放到一个列表中,第13行代码通过`asyncio`模块的`get_event_loop`函数获得了系统的事件循环,第14行通过`asyncio`模块的`run_until_complete`函数将协程对象挂载到事件循环上。执行上面的代码会发现,`9`个分别会阻塞`1`秒钟的协程总共只阻塞了约`1`秒种的时间,因为**阻塞的协程对象会放弃对 CPU 的占有而不是让 CPU 处于闲置状态,这种方式大大的提升了 CPU 的利用率**。而且我们还会注意到,数字并不是按照从`1`到`9`的顺序打印输出的,这正是我们想要的结果,说明它们是**异步执行**的。对于爬虫这样的 I/O 密集型任务来说,这种协作式并发在很多场景下是比使用多线程更好的选择,因为这种做法减少了管理和维护多个线程以及多个线程切换所带来的开销。 + +### aiohttp库 + +我们之前使用的`requests`三方库并不支持异步 I/O,如果希望使用异步 I/O 的方式来加速爬虫代码的执行,我们可以安装和使用名为`aiohttp`的三方库。 + +安装`aiohttp`。 + +```Bash +pip install aiohttp +``` + +下面的代码使用`aiohttp`抓取了`10`个网站的首页并解析出它们的标题。 + +```Python +import asyncio +import re + +import aiohttp +from aiohttp import ClientSession + +TITLE_PATTERN = re.compile(r'(.*?)', re.DOTALL) + + +async def fetch_page_title(url): + async with aiohttp.ClientSession(headers={ + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36', + }) as session: # type: ClientSession + async with session.get(url, ssl=False) as resp: + if resp.status == 200: + html_code = await resp.text() + matcher = TITLE_PATTERN.search(html_code) + title = matcher.group(1).strip() + print(title) + + +def main(): + urls = [ + 'https://www.python.org/', + 'https://www.jd.com/', + 'https://www.baidu.com/', + 'https://www.taobao.com/', + 'https://git-scm.com/', + 'https://www.sohu.com/', + 'https://gitee.com/', + 'https://www.amazon.com/', + 'https://www.usa.gov/', + 'https://www.nasa.gov/' + ] + objs = [fetch_page_title(url) for url in urls] + loop = asyncio.get_event_loop() + loop.run_until_complete(asyncio.wait(objs)) + loop.close() + + +if __name__ == '__main__': + main() +``` + +输出: + +``` +京东(JD.COM)-正品低价、品质保障、配送及时、轻松购物! +搜狐 +淘宝网 - 淘!我喜欢 +百度一下,你就知道 +Gitee - 基于 Git 的代码托管和研发协作平台 +Git +NASA +Official Guide to Government Information and Services | USAGov +Amazon.com. Spend less. Smile more. +Welcome to Python.org +``` + +从上面的输出可以看出,网站首页标题的输出顺序跟它们的 URL 在列表中的顺序没有关系。代码的第11行到第13行创建了`ClientSession`对象,通过它的`get`方法可以向指定的 URL 发起请求,如第14行所示,跟`requests`中的`Session`对象并没有本质区别,唯一的区别是这里使用了异步上下文。代码第16行的`await`会让因为 I/O 操作阻塞的子程序放弃对 CPU 的占用,这使得其他的子程序可以运转起来去抓取页面。代码的第17行和第18行使用了正则表达式捕获组操作解析网页标题。`fetch_page_title`是一个被`async`关键字修饰的异步函数,调用该函数会获得协程对象,如代码第35行所示。后面的代码跟之前的例子没有什么区别,相信大家能够理解。 + +大家可以尝试将`aiohttp`换回到`requests`,看看不使用异步 I/O 也不使用多线程,到底和上面的代码有什么区别,相信通过这样的对比,大家能够更深刻的理解我们之前强调的几个概念:同步和异步,阻塞和非阻塞。 diff --git a/第37课:并发编程在爬虫中的应用.md b/第37课:并发编程在爬虫中的应用.md new file mode 100644 index 0000000..6662c24 --- /dev/null +++ b/第37课:并发编程在爬虫中的应用.md @@ -0,0 +1,185 @@ +## 第37课:并发编程在爬虫中的应用 + +之前的课程,我们已经为大家介绍了 Python 中的多线程、多进程和异步编程,通过这三种手段,我们可以实现并发或并行编程,这一方面可以加速代码的执行,另一方面也可以带来更好的用户体验。爬虫程序是典型的 I/O 密集型任务,对于 I/O 密集型任务来说,多线程和异步 I/O 都是很好的选择,因为当程序的某个部分因 I/O 操作阻塞时,程序的其他部分仍然可以运转,这样我们不用在等待和阻塞中浪费大量的时间。下面我们以爬取“[360图片](https://image.so.com/)”网站的图片并保存到本地为例,为大家分别展示使用单线程、多线程和异步 I/O 编程的爬虫程序有什么区别,同时也对它们的执行效率进行简单的对比。 + +“360图片”网站的页面使用了 [Ajax](https://developer.mozilla.org/zh-CN/docs/Web/Guide/AJAX) 技术,这是很多网站都会使用的一种异步加载数据和局部刷新页面的技术。简单的说,页面上的图片都是通过 JavaScript 代码异步获取 JSON 数据并动态渲染生成的,而且整个页面还使用了瀑布式加载(一边向下滚动,一边加载更多的图片)。我们在浏览器的“开发者工具”中可以找到提供动态内容的数据接口,如下图所示,我们需要的图片信息就在服务器返回的 JSON 数据中。 + + + +例如,要获取“美女”频道的图片,我们可以请求如下所示的URL,其中参数`ch`表示请求的频道,`=`后面的参数值`beauty`就代表了“美女”频道,参数`sn`相当于是页码,`0`表示第一页(共`30`张图片),`30`表示第二页,`60`表示第三页,以此类推。 + +``` +https://image.so.com/zjl?ch=beauty&sn=0 +``` + +### 单线程版本 + +通过上面的 URL 下载“美女”频道共`90`张图片。 + +```Python +""" +example04.py - 单线程版本爬虫 +""" +import os + +import requests + + +def download_picture(url): + filename = url[url.rfind('/') + 1:] + resp = requests.get(url) + if resp.status_code == 200: + with open(f'images/beauty/{filename}', 'wb') as file: + file.write(resp.content) + + +def main(): + if not os.path.exists('images/beauty'): + os.makedirs('images/beauty') + for page in range(3): + resp = requests.get(f'https://image.so.com/zjl?ch=beauty&sn={page * 30}') + if resp.status_code == 200: + pic_dict_list = resp.json()['list'] + for pic_dict in pic_dict_list: + download_picture(pic_dict['qhimg_url']) + +if __name__ == '__main__': + main() +``` + +在 macOS 或 Linux 系统上,我们可以使用`time`命令来了解上面代码的执行时间以及 CPU 的利用率,如下所示。 + +```Bash +time python3 example04.py +``` + +下面是单线程爬虫代码在我的电脑上执行的结果。 + +``` +python3 example04.py 2.36s user 0.39s system 12% cpu 21.578 total +``` + +这里我们只需要关注代码的总耗时为`21.578`秒,CPU 利用率为`12%`。 + +### 多线程版本 + +我们使用之前讲到过的线程池技术,将上面的代码修改为多线程版本。 + +```Python +""" +example05.py - 多线程版本爬虫 +""" +import os +from concurrent.futures import ThreadPoolExecutor + +import requests + + +def download_picture(url): + filename = url[url.rfind('/') + 1:] + resp = requests.get(url) + if resp.status_code == 200: + with open(f'images/beauty/{filename}', 'wb') as file: + file.write(resp.content) + + +def main(): + if not os.path.exists('images/beauty'): + os.makedirs('images/beauty') + with ThreadPoolExecutor(max_workers=16) as pool: + for page in range(3): + resp = requests.get(f'https://image.so.com/zjl?ch=beauty&sn={page * 30}') + if resp.status_code == 200: + pic_dict_list = resp.json()['list'] + for pic_dict in pic_dict_list: + pool.submit(download_picture, pic_dict['qhimg_url']) + + +if __name__ == '__main__': + main() +``` + +执行如下所示的命令。 + +```Bash +time python3 example05.py +``` + +代码的执行结果如下所示: + +``` +python3 example05.py 2.65s user 0.40s system 95% cpu 3.193 total +``` + +### 异步I/O版本 + +我们使用`aiohttp`将上面的代码修改为异步 I/O 的版本。为了以异步 I/O 的方式实现网络资源的获取和写文件操作,我们首先得安装三方库`aiohttp`和`aiofile`,命令如下所示。 + +```Bash +pip install aiohttp aiofile +``` + +`aiohttp` 的用法在之前的课程中已经做过简要介绍,`aiofile`模块中的`async_open`函数跟 Python 内置函数`open`的用法大致相同,只不过它支持异步操作。下面是异步 I/O 版本的爬虫代码。 + +```Python +""" +example06.py - 异步I/O版本爬虫 +""" +import asyncio +import json +import os + +import aiofile +import aiohttp + + +async def download_picture(session, url): + filename = url[url.rfind('/') + 1:] + async with session.get(url, ssl=False) as resp: + if resp.status == 200: + data = await resp.read() + async with aiofile.async_open(f'images/beauty/{filename}', 'wb') as file: + await file.write(data) + + +async def fetch_json(): + async with aiohttp.ClientSession() as session: + for page in range(3): + async with session.get( + url=f'https://image.so.com/zjl?ch=beauty&sn={page * 30}', + ssl=False + ) as resp: + if resp.status == 200: + json_str = await resp.text() + result = json.loads(json_str) + for pic_dict in result['list']: + await download_picture(session, pic_dict['qhimg_url']) + + +def main(): + if not os.path.exists('images/beauty'): + os.makedirs('images/beauty') + loop = asyncio.get_event_loop() + loop.run_until_complete(fetch_json()) + loop.close() + + +if __name__ == '__main__': + main() +``` + +执行如下所示的命令。 + +```Bash +time python3 example06.py +``` + +代码的执行结果如下所示: + +``` +python3 example06.py 0.82s user 0.21s system 27% cpu 3.782 total +``` + +### 总结 + +通过上面三段代码执行结果的比较,我们可以得出一个结论,使用多线程和异步 I/O 都可以改善爬虫程序的性能,因为我们不用将时间浪费在因 I/O 操作造成的等待和阻塞上,而`time`命令的执行结果也告诉我们,单线程的代码 CPU 利用率仅仅只有`12%`,而多线程版本的 CPU 利用率则高达`95%`;单线程版本的爬虫执行时间约`21`秒,而多线程和异步 I/O 的版本仅执行了`3`秒钟。另外,在运行时间差别不大的情况下,多线程的代码比异步 I/O 的代码耗费了更多的 CPU 资源,这是因为多线程的调度和切换也需要花费 CPU 时间。至此,三种方式在 I/O 密集型任务上的优劣已经一目了然,当然这只是在我的电脑上跑出来的结果。如果网络状况不是很理想或者目标网站响应很慢,那么使用多线程和异步 I/O 的优势将更为明显,有兴趣的读者可以自行试验。