网络与并发编程|第七部分:Python标准库精选 (The Standard Library)
欢迎来到Python的“高速公路”和“多核处理器”时代!到目前为止,我们编写的程序大多是单线程的、顺序执行的。就像一个勤奋但只有一个人的工坊,一次只能做一件事,做完一件再做下一件。
但是,现代计算机拥有多个CPU核心,网络请求也充满了等待。如果我们能让程序在等待网络响应的时候,去做别的事情,或者同时利用多个CPU核心来并行处理任务,程序的效率将会得到极大的提升。这就是并发编程的魅力。
本章,我们将探索两个激动人心的主题:
- 网络编程: 我们将学习如何使用
urllib
和socket
模块,让你的Python程序能够像浏览器一样发送网络请求,获取网页内容,或者构建底层的网络服务。 - 并发编程 (多线程): 我们将深入
threading
模块,学习如何创建和管理多个线程,让你的程序能够同时执行多个任务。你将理解线程的生命周期、如何保证数据在多线程下的安全(锁),以及如何避免可怕的死锁。
掌握了这些,你的程序将不再是一个孤立的、单任务的个体,而是一个能够连接世界、高效利用资源的现代化应用。
18.1 urllib
与 socket
模块
urllib
:简单高效的HTTP客户端
urllib
是Python内置的HTTP请求库,它包含多个模块,其中 urllib.request
是最常用的,可以让你轻松地像浏览器一样访问网页。
注意: 在实际项目中,更推荐使用第三方库 requests
,它的API设计得更友好、更简洁。但了解标准库的 urllib
依然非常重要。
from urllib import request, parse, error
# --- 1. 发送一个简单的 GET 请求 ---
url = "https://www.python.org"
try:
with request.urlopen(url) as response:
# response 对象类似于一个文件对象
print(f"状态码: {response.status}")
print(f"响应头: \n{response.getheaders()}")
# 读取内容 (内容是 bytes 类型,需要解码)
html_content = response.read().decode('utf-8')
print("\n--- 网页内容 (前100个字符) ---")
print(html_content[:100])
except error.URLError as e:
print(f"访问URL时出错: {e.reason}")
# --- 2. 发送一个带参数的 GET 请求 ---
# 比如搜索 ?q=python
base_url = "https://www.bing.com/search"
params = {"q": "python tutorial"}
query_string = parse.urlencode(params) # 将字典编码成 "q=python+tutorial"
full_url = f"{base_url}?{query_string}"
print(f"\n构造的URL: {full_url}")
# 后续的请求方式同上
socket
:网络编程的基石
urllib
是高层封装,而 socket
模块则提供了底层的、通用的网络套接字接口。它是所有网络通信(包括HTTP、FTP、SMTP等)的基础。使用 socket
,你可以编写自己的服务器和客户端。
这是一个非常底层的模块,我们通过一个极简的“回声服务器”来感受一下它的工作流程:
# --- 服务器端 (echo_server.py) ---
import socket
# 1. 创建 socket 对象
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 2. 绑定地址和端口
server_socket.bind(('127.0.0.1', 9999))
# 3. 开始监听连接
server_socket.listen(5)
print("服务器已启动,等待客户端连接...")
while True:
# 4. 接受客户端连接 (这是一个阻塞操作)
client_socket, addr = server_socket.accept()
print(f"接收到来自 {addr} 的连接")
# 5. 接收数据
data = client_socket.recv(1024) # 一次最多接收1024字节
print(f"收到数据: {data.decode('utf-8')}")
# 6. 发送数据 (将收到的数据原样返回)
client_socket.send(data)
# 7. 关闭客户端连接
client_socket.close()
# --- 客户端 (echo_client.py) ---
import socket
# 1. 创建 socket 对象
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 2. 连接服务器
client_socket.connect(('127.0.0.1', 9999))
# 3. 发送数据
message = "Hello, Server!"
client_socket.send(message.encode('utf-8'))
# 4. 接收服务器返回的数据
response = client_socket.recv(1024)
print(f"收到服务器的回应: {response.decode('utf-8')}")
# 5. 关闭连接
client_socket.close()
如何运行: 先运行 echo_server.py
,然后再运行 echo_client.py
,你就能看到它们之间完成了通信。
18.2 并发编程: 多线程 (threading
模块)
线程是操作系统能够进行运算调度的最小单位。一个进程(比如你运行的Python程序)可以包含多个线程,它们共享进程的内存空间,可以同时执行不同的任务。
多线程的适用场景: 主要用于 I/O密集型 任务。比如,当你的程序需要等待网络响应、等待文件读写时,CPU是空闲的。多线程可以让CPU在这段空闲时间里去执行其他线程的任务,从而提高整体效率。
注意: 由于Python的全局解释器锁(GIL),在同一时刻,一个Python进程中只有一个线程能真正执行Python字节码。因此,多线程对于CPU密集型任务(如大规模科学计算)并不能实现真正的并行加速。对于CPU密集型任务,应该使用多进程 (multiprocessing
模块)。
线程生命周期与创建
import threading
import time
def worker(name, delay):
"""一个简单的线程工作函数"""
print(f"线程 {name}: 开始工作...")
time.sleep(delay)
print(f"线程 {name}: 工作完成。")
# --- 创建和启动线程 ---
# 创建 Thread 对象,target 指定线程要执行的函数,args 是传递给函数的参数 (必须是元组)
thread1 = threading.Thread(target=worker, args=("A", 2))
thread2 = threading.Thread(target=worker, args=("B", 4))
print("主线程: 准备启动子线程。")
# 启动线程
thread1.start()
thread2.start()
print("主线程: 子线程已启动,我继续做别的事情...")
# 等待子线程结束
thread1.join() # join() 会阻塞主线程,直到 thread1 执行完毕
thread2.join()
print("主线程: 所有子线程都已结束。")
运行以上代码,你会看到线程A和B是“同时”开始的,主线程在它们运行期间也可以继续执行。
线程同步 (锁, 事件, 条件变量)
当多个线程共享和修改同一个数据时,可能会导致数据错乱,这就是线程不安全。想象一下,两个线程同时对一个银行账户余额(初始100)执行 balance += 10
,可能会出现一个线程刚读取了100,还没写回110,另一个线程也读取了100,最后两个线程都写回110,结果是110而不是期望的120。
为了解决这个问题,我们需要线程同步机制,最常用的就是锁 (Lock)。
balance = 0
lock = threading.Lock() # 创建一个锁对象
def change_balance(n):
global balance
# --- 临界区代码 ---
lock.acquire() # 获取锁,只有一个线程能成功获取,其他线程会在此等待
try:
# 这里的操作是线程安全的
current_balance = balance
time.sleep(0.01) # 模拟一些耗时操作
balance = current_balance + n
finally:
lock.release() # 必须释放锁,否则其他线程将永远等待
# 创建多个线程同时修改余额
threads = []
for i in range(100):
t = threading.Thread(target=change_balance, args=(1,))
threads.append(t)
t.start()
for t in threads:
t.join()
print(f"最终余额: {balance}") # 如果没有锁,结果会不确定;有锁,结果总是 100
线程死锁与避免
死锁是多线程编程中最可怕的问题之一。它指的是两个或多个线程互相持有对方需要的资源,并无限期地等待对方释放,导致所有相关线程都无法继续执行。
经典案例:哲学家就餐问题
想象两个线程(哲学家)A和B,以及两把锁(筷子)L1和L2。
- 线程A:先获取L1,再尝试获取L2。
- 线程B:先获取L2,再尝试获取L1。
如果A获取了L1,同时B获取了L2,那么A将永远等待B释放L2,B也永远等待A释放L1,死锁发生!
避免死锁的策略:
- 按序加锁: 规定所有线程都必须按照相同的顺序来获取锁(例如,总是先获取L1,再获取L2)。
- 设置超时: 在尝试获取锁时使用超时参数
lock.acquire(timeout=...)
,如果超时还未获取到,就先释放自己已有的锁,稍后再试。
线程池 (concurrent.futures
)
频繁地创建和销毁线程是有开销的。线程池是一种预先创建好一定数量的线程,并将任务提交给它们去执行的模式,可以有效地复用线程,管理并发任务。
Python 3.2 引入的 concurrent.futures
模块提供了高级的、易于使用的接口来处理线程池和进程池。
from concurrent.futures import ThreadPoolExecutor
import time
def task(n):
print(f"正在执行任务 {n}...")
time.sleep(2)
return f"任务 {n} 的结果"
# 创建一个最多包含3个线程的线程池
with ThreadPoolExecutor(max_workers=3) as executor:
# 提交任务到线程池
future1 = executor.submit(task, 1)
future2 = executor.submit(task, 2)
future3 = executor.submit(task, 3)
future4 = executor.submit(task, 4) # 任务4会等待前3个任务之一完成后才开始
# 获取任务的结果 (future.result() 是一个阻塞操作)
print(future1.result())
print(future2.result())
print(future3.result())
print(future4.result())
ThreadPoolExecutor
结合 with
语句使用,可以自动管理线程池的关闭,代码非常简洁优雅。
太了不起了!你现在已经将你的Python技能从本地扩展到了网络,从单核思维升级到了并发思维。你学会了:
- 如何使用
urllib
获取网络资源。 - 如何使用
socket
构建底层的网络应用。 - 多线程的基本概念、创建和管理。
- 如何使用锁来保证线程安全,并了解了死锁的成因与避免方法。
- 如何使用现代的线程池来高效地管理并发任务。
你的程序已经具备了现代化应用的核心能力。在下一章,我们将关注程序的“健康与维护”。你将学习如何进行调试、测试与日志记录,这些是保证软件质量、让你的程序在复杂环境中稳定运行的“三大法宝”。准备好,成为一名专业的、负责任的软件工程师吧!