最近在写python,做了一个抓取150个网站标题的脚本,被python的便捷、多线程和执行稳定性感动的要哭。然后想起了去年草稿箱里面的这篇文章,回首再看感慨万分,赶紧把剩下的部分写完,关于php多线程curl的几个大坑。
需求大概是这样:
要查询150个网站的存活情况,并抓取目标网站的标题。
实现起来很简单,将150个网站添加到循环,逐一执行curl即可。但是作为一个有点追求的码农,即便是150个网站也想着怎么去优化下,于是便使用了php的多线程curl(即curl_multi_*),没想到手册资料少,坑踩了一个又一个。
特此记录,分享&备忘。
0x01 相关函数 curl_multi_*
php手册参考:
http://php.net/manual/zh/book.curl.php
curl_multi_init()相关:
http://php.net/manual/zh/function.curl-multi-init.php
手册说的很简单,并附了一个演示脚本,如下。
0); // 关闭全部句柄 curl_multi_remove_handle($mh, $ch1); curl_multi_remove_handle($mh, $ch2); curl_multi_close($mh);
演示脚本说明了工作流程。
1、curl_multi_init,初始化多线程curl批处理会话。
2、curl_multi_add_handle,将具体执行任务的curl添加到multi_curl批处理会话。
3、curl_multi_exec,真正开始执行curl任务。
4、curl_multi_getcontent,获取执行结果。
5、curl_multi_remove_handle,将完成了的curl移出批处理会话。
6、curl_multi_close,关闭curl批处理会话。
0x02 问题来了
好了,问题来了,不是挖掘机。
使用以上脚本会出现多个问题:
1、在执行do...while语句时经常死循环,无法停止。
2、为每个curl实例设置了超时时间,但是整个脚本执行时间跟这个超时时间竟然相同!这不科学!
3、多线程在哪里?php自动处理的?
4、(增强版问题)改进了脚本,但是执行结果不稳定。添加150个目标时,经常只有140+个目标有返回结果,出现随机丢掉几个任务的情况。
0x03 踩坑和解决问题
关于这个死循环的问题,很多人也遇到过,在php的在线手册上,就有评论说明了这个问题:
http://php.net/manual/zh/function.curl-multi-init.php#115055
http://php.net/manual/zh/function.curl-multi-select.php#110869
http://php.net/manual/zh/function.curl-multi-select.php#108928
简单来说就是curl_multi_select()可能会一直返回-1,如果写出了类似上面的代码可能就会遇到死循环了。注意这些问题除了受代码编写的影响,还受php和libcurl版本的影响,总而言之升级版本吧。
至于像 CURLM_CALL_MULTI_PERFORM 之类的预定义常量,php方面并没有详细解释,多半靠看名字猜,呵呵。
其实这些常量是由libcurl库定义的,参考地址:
http://curl.haxx.se/libcurl/c/libcurl-errors.html
CURLM_CALL_MULTI_PERFORM (-1)
This is not really an error. It means you should call curl_multi_perform again without doing select() or similar in between. Before version 7.20.0 this could be returned by curl_multi_perform, but in later versions this return code is never used.
当返回值为-1时,并不意味着这是一个错误,只是说明select时没有并没有完成excute,描述给的建议是不要执行select等阻塞操作,立即exec。
但是在libcurl的7.20版本之后,不再使用这个返回值了,原因是这个循环libcurl自己做了,就不再需要我们手动循环了。
同时注意curl_multi_select,其实还有第二个参数timeout,根据语焉不详的手册,这货应该是自带阻塞,所以就不再需要手动sleep了。
综上我们的代码看起来应该是这样的:
(.*)<\/title>/isU", $html, $title); return empty($title[1]) ? '未能获取标题' : $title[1]; } echo "抓取完成!\n"; $end_time = microtime(); $start_time = explode(" ", $start_time); $end_time = explode(" ",$end_time); $execute_time = round(($end_time[0] - $start_time[0] + $end_time[1] - $start_time[1]) * 1000) / 1000; $execute_time = sprintf("%s", $execute_time); echo "脚本运行时间:{$execute_time} 秒\n";然后就遇到了问题2和3。
整个脚本的执行时间就是30秒多一点,刚好是为每个curl设置的超时时间,这显然不科学啊。
执行速度确实挺快,30秒也获取到了相当数量的标题,但是多线程体现在哪?这是多少线程?
这俩问题曾经困扰我很长一段时间。。。其实答案很简单。。。
线程数就是150,所以这150个请求在同时完成,整个脚本的执行时间就是30秒多一点。
但其实php并不能很好的处理这150个线程,导致很多目标获取标题失败了。另外我如果有150k目标要请求,难道要开150k个线程?
这就需要自己实现一个线程池,来掌控任务进度。
思路就是用curl_multi_remove_handle一次添加n个url到multi_curl中,这个n就是线程数,这n个的组合队列就是线程池。
每执行完毕一个任务,就将对应的curl移除队列并销毁,同时加入新的目标,直至150个对象依次执行完毕。这样做的好处是,能保证线程池中始终有n个任务在进行,不必等这n个任务执行完毕后再执行下n个任务。
思路有了,所以我们的代码看来是这样的:
0) break; }while($running); while($info = curl_multi_info_read($mh)){ $ch = $info['handle']; $url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); $total_time += curl_getinfo($ch, CURLINFO_TOTAL_TIME); if($info['result'] == CURLE_OK){ $content = curl_multi_getcontent($ch); $detail = getTitle($content); }else $detail = 'cURL Error(' . curl_error($ch) . ")."; curl_multi_remove_handle($mh, $ch); curl_close($ch); unset($ch); if($targets){ $new_task = curl_init(array_pop($targets)); curl_setopt_array($new_task, $opt); curl_multi_add_handle($mh, $new_task);//要设置每个curl实例的属性 // 手动执行,保证 $running 更新,感谢@rainyluo 反馈,update@2016-04-16。 curl_multi_exec($mh, $running); } echo "[$running][$index][", date('H:i:s'), "]{$url}:{$detail}\n"; $index++; } }while($running); curl_multi_close($mh); function getTitle($html){ preg_match("/(.*)<\/title>/isU", $html, $title); return empty($title[1]) ? '未能获取标题' : $title[1]; } echo "抓取完成!\n"; $end_time = microtime(); $start_time = explode(" ", $start_time); $end_time = explode(" ", $end_time); $execute_time = round(($end_time[0] - $start_time[0] + $end_time[1] - $start_time[1]) * 1000) / 1000; $execute_time = sprintf("%s", $execute_time); echo "http请求时间:{$total_time} 秒\n"; echo "脚本运行时间:{$execute_time} 秒\n";
上面的代码执行效果就比较理想了,问题3也解决了,只需要注意两个小地方。
一是关于multi_curl_select函数,这个函数手册是这么说的:
成功时返回描述符集合中描述符的数量。失败时,select失败时返回-1,否则返回超时(从底层的select系统调用).
On success, returns the number of descriptors contained in the descriptor sets. This may be 0 if there was no activity on any of the descriptors. On failure, this function will return -1 on a select failure (from the underlying select system call).
擦他大爷,这中文翻译绝壁是机翻,对我造成了100000000点伤害。
-1 的返回值是从系统底层调用产生的,应该是libcurl给php的返回结果,这说明select执行失败,需要阻塞一段时间后再次执行;0是没有任务活动 链接,据我观察(- -目测的,深究的话得去看php的代码,请路过的大牛们指正),应该是底层请求处于阻塞状态,可能是正在解析域名或者timeout进行中,或者mh中所 有任务执行完毕;正整数表示有正常的活动链接,说明mh中还有未完成的任务。
为了细化处理多线程curl每个请求的执行结果,我在curl_multi_select的返回值大于0的时候也跳出了当前exec循环,并通过curl_multi_info_read来获取已经完成的任务信息。这里封装下就可以做个回调,精细化处理每个任务。
另 一个地方就是注意curl_multi_info_read需要多次调用。这个函数每次调用返回已经完成的任务信息,直至没有已完成的任务。问题4产生的 原因就是因为我当时用了if没用while,这是一个小坑,但坑了我相当长的时间。当时非常无奈的解决方式是监控了整个执行过程,在所有任务完成后清点队 列,把遗漏的再取出来。。。
0x04 总结
上面的代码只是demo,按照面向过程的方式写了出来。如果要用在其他地方,还得把线程池管理,任务回调等再封装下。然后配合一些html解析库就能做个小爬虫自娱自乐了。。。
php在多线程和异步等方面存在天生的缺陷,很多东西php能写,但是效果不如python,python实现起来可能更容易、更轻松。还是得看使用场景啊,不过php依然是最好的语言 :)