刚看到一个帖子,讨论当下很多公司不愿招聘35岁以上程序员的现象。发帖的楼主去年换了份新工作,他所在的组里有三位成员年龄都在40岁以上。根据他的观察,核心问题其实并非体力和精力跟不上,而是某些工作状态和思维习惯上出现了偏差。

帖子下面的评论五花八门,有人抨击资本家压榨年轻人、嫌弃老员工,也有人为资深开发者鸣不平,认为丰富的经验不该被忽视。在我看来,这个问题不能简单地归咎于某一方。
从企业的角度算的是一笔“性价比”的账:技术更新迭代能否跟上、是否愿意主动学习新东西、出现问题敢不敢承担责任,以及日常工作中是积极解决问题还是习惯于抱怨。年龄本身不是问题,但若仗着资历止步不前,那才是症结所在。如果拿着高薪却只愿做轻松稳定的工作,抗拒加班、拒绝学习新框架,那么老板在权衡成本和风险时,自然会有所考量。
算法题:多线程网页爬虫
有天晚上十一点多,我在公司楼下拿着手机查看日志,旁边组里的小李嚷嚷道:“哥,你那个爬虫跑得也太慢了,一页一页跟蜗牛爬似的。” 我凑过去一看,他写的是一个最基础的单线程爬虫,用 requests.get 从头到尾串行执行,完全没有利用多线程并发处理的潜力。
我们先把这个题目说清楚。所谓多线程网页爬虫,通俗讲就是“同时启动多个小工人,一起去帮你抓取网页数据”,而不是一个人干完所有活再换下一个。
单线程爬虫的工作流程,通常就这几步:
- 获取一个目标 URL
- 发送网络请求,拉取网页内容
- 从 HTML 中解析出需要的数据(如标题、正文)
- 顺便提取页面中的新链接,加入待抓取列表
整个逻辑并不复杂,但效率瓶颈明显。因为网络请求(I/O 操作)是最耗时的环节。单线程模式下,程序在“发送请求 -> 等待响应 -> 处理结果”这个循环中,大部分时间 CPU 都在空闲等待。
这时就该多线程登场了。众所周知,Python 有 GIL(全局解释器锁),但它主要制约的是“CPU 密集型”任务。对于“网络 I/O 密集型”的爬虫场景,多线程依然能大幅提升吞吐量。你可以这样理解:GIL 规定一次只能有一个人掌勺炒菜(CPU 计算),但你现在是在打电话订外卖(网络请求),电话拨通后大部分时间是在等待,这时候完全可以多开几条线路同时打。
我当时给小李调整的思路,可以先抛开代码,想象这样一个场景:
- 有一个“待爬取任务队列”,里面存放了初始的 URL。
- 旁边有一群“工人”,也就是多个线程,不断地从队列里领取任务。
- 每个工人完成一个任务后,做好标记,然后立刻返回队列领取下一个。
- 同时必须做好协调,防止多个工人重复处理同一个 URL。
将这个思路落实到 Python 代码中,一个简易但能说明问题的版本如下:
import threading
import queue
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse
class MultiThreadCrawler:
def __init__(self, start_url, max_workers=5, max_pages=50):
self.start_url = start_url
self.max_workers = max_workers
self.max_pages = max_pages
self.task_queue = queue.Queue()
self.task_queue.put(start_url)
self.visited = set()
self.visited_lock = threading.Lock()
def crawl(self):
threads = []
for _ in range(self.max_workers):
t = threading.Thread(target=self.worker)
t.daemon = True
t.start()
threads.append(t)
# 等待队列中的所有任务被处理完
self.task_queue.join()
# 等待所有工作线程结束(这里采用简单策略)
for t in threads:
t.join(timeout=0.1)
def worker(self):
while True:
try:
url = self.task_queue.get(timeout=1)
except queue.Empty:
break
with self.visited_lock:
if url in self.visited or len(self.visited) >= self.max_pages:
self.task_queue.task_done()
continue
self.visited.add(url)
self.fetch_and_parse(url)
self.task_queue.task_done()
def fetch_and_parse(self, url):
try:
resp = requests.get(url, timeout=5)
except Exception as e:
print("请求失败:", url, e)
return
if resp.status_code != 200:
print("状态码异常:", url, resp.status_code)
return
print("爬到一页:", url)
# 示例性解析,这里仅提取页面标题
soup = BeautifulSoup(resp.text, "html.parser")
title = soup.title.string.strip() if soup.title and soup.title.string else ""
if title:
print("标题:", title)
# 将页面中的新链接加入任务队列
for a in soup.find_all("a", href=True):
new_url = urljoin(url, a["href"])
# 限制只爬取同一域名下的链接,避免失控
if urlparse(new_url).netloc == urlparse(self.start_url).netloc:
with self.visited_lock:
if new_url not in self.visited:
self.task_queue.put(new_url)
if __name__ == "__main__":
start = "https://example.com"
crawler = MultiThreadCrawler(start_url=start, max_workers=10, max_pages=100)
crawler.crawl()
观察这段代码的结构,其核心设计包含几个关键点:
- 一个共享的
task_queue 用作任务队列。
- 多个
worker 线程,持续从中获取 URL 进行处理。
- 一个
visited 集合,配合锁 (visited_lock) 确保链接不会被重复处理。
- 通过
max_pages 参数控制总体抓取规模,防止程序失控。
这里有几个实践中容易踩到的“坑”,值得特别注意:
第一,去重操作必须加锁。 如果不给 visited 集合的访问加上锁,在高并发下很可能出现:两个线程几乎同时判断某个 URL “未被访问过”,然后都去抓取一遍。这不仅浪费资源,在监控端看来可能像是一场自我发起的 DDOS 攻击。
第二,线程数并非越多越好。 盲目开启成百上千个线程,多线程调度和上下文切换的开销反而会拖慢整体速度。对于这类 I/O 密集型任务,我通常建议先在 5 到 30 个线程之间试验,结合 max_pages 观察总耗时,再进行微调。若追求极高的并发,可以考虑 asyncio 等异步方案,但那是另一个话题了。
第三,需具备“爬虫道德”。 如果每秒向目标网站发起上百个请求,很可能会被对方封禁 IP。可以在 fetch_and_parse 函数中适当加入 sleep,或者使用限流器控制请求速率。更精细的做法是为不同域名配置独立的请求间隔控制器。
第四,异常处理要稳健。 requests.get 可能超时,页面解析可能遇到乱码,服务器可能返回 500 错误。如果这些异常没有妥善捕获和处理,很可能导致单个线程崩溃退出。你以为程序在高效并发,实际上工作线程可能已所剩无几。
可能有人会问:这也能算“算法”吗?不就是多线程编程吗?如果我们跳出代码细节,从更高层面看,其本质是:
- 用一个队列对网站的链接图进行广度优先遍历 (BFS)。
- 用多个 worker 并发消费任务,相当于将“访问节点”的操作并行化。
- 通过
visited 集合确保“每个节点只访问一次”,防止循环和重复。
- 叠加各种约束条件(深度、页面数、域名限制)来控制搜索范围。
这样一听,是不是感觉熟悉多了?这其实就是图论中遍历思想的一个具体应用外壳。
说到这儿,我手机又响了,是小李发来的消息:“哥,那个爬虫能不能再加个按关键词过滤的功能……” 算了,这个改动讲起来又是长篇大论,下次有机会再聊吧。技术讨论永无止境,保持学习和分享的心态,才是应对各种挑战的关键。关于职业发展与技术成长的更多讨论,也欢迎来云栈社区交流。