php的多线程curl/curl_multi

这篇日志发布时间已经超过一年,许多内容可能已经失效,请读者酌情参考。

最近在写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

手册说的很简单,并附了一个演示脚本,如下。

<?php
// 创建一对cURL资源
$ch1 = curl_init();
$ch2 = curl_init();

// 设置URL和相应的选项
curl_setopt($ch1, CURLOPT_URL, "http://www.example.com/");
curl_setopt($ch1, CURLOPT_HEADER, 0);
curl_setopt($ch2, CURLOPT_URL, "http://www.php.net/");
curl_setopt($ch2, CURLOPT_HEADER, 0);

// 创建批处理cURL句柄
$mh = curl_multi_init();

// 增加2个句柄
curl_multi_add_handle($mh,$ch1);
curl_multi_add_handle($mh,$ch2);

$running=null;
// 执行批处理句柄
do {
    usleep(10000);
    curl_multi_exec($mh,$running);
} while ($running > 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

<?php 
//bad example.当你写出类似下面的代码,可能就会遇到死循环的问题
while ( $active and $mrc == CURLM_OK ) { 
  if (curl_multi_select ( $mh ) != - 1) {
    
   do {
    $mrc = curl_multi_exec ( $mh, $active );
    
   } while ( $mrc == CURLM_CALL_MULTI_PERFORM );
   
  }
 
}
//bad example.或者下面的
while(curl_multi_select()==-1){
    //pass
}

简单来说就是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了。

综上我们的代码看起来应该是这样的:

<?php
//bad example. 错误的例子
$start_time = microtime();
date_default_timezone_set('PRC');

$targets = array();//150个网址,自行添加目标

$total = count($targets);
echo "共有{$total}个目标:\n";

$mh = curl_multi_init();

$opt = array ();
$opt[CURLOPT_HEADER] = false;
$opt[CURLOPT_CONNECTTIMEOUT] = 15;
$opt[CURLOPT_TIMEOUT] = 30;
$opt[CURLOPT_AUTOREFERER] = true;
$opt[CURLOPT_RETURNTRANSFER] = true;
$opt[CURLOPT_FOLLOWLOCATION] = true;
$opt[CURLOPT_MAXREDIRS] = 10;

foreach($targets as $target){
    $ch = curl_init($target);
    curl_setopt_array($ch, $opt);
    curl_multi_add_handle($mh, $ch);//要设置每个curl实例的属性
    unset($ch);
}

$index = 1;
do{
    do{
        curl_multi_exec($mh, $running);
        curl_multi_select($mh, 1.0);
    }while($running);

    while($info = curl_multi_info_read($mh)){
            $ch = $info['handle'];
            $url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
    
    if($info['result'] == CURLE_OK){
                $content = curl_multi_getcontent($ch);
                $detail = getTitle($content);
            }else
                $detail =  'cURL Error(' . curl_error($ch) . ").";
    
    echo "[$index][", date('H:i:s'), "]{$url}:{$detail}\n";
            $index++;
    }

}while($running);

curl_multi_close($mh);

function getTitle($html){
    preg_match("/<title>(.*)<\/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个任务。

思路有了,所以我们的代码看来是这样的:

<?php
$start_time = microtime();
date_default_timezone_set('PRC');

$targets = array();//150个网址,自行添加目标

$total = count($targets);

$mh_pool = array();
$threads = 10;
$total_time = 0;
echo "共有{$total}个目标:\n";
echo "线程数:{$threads}\n";

$mh = curl_multi_init();

$opt = array ();
$opt[CURLOPT_HEADER] = false;
$opt[CURLOPT_CONNECTTIMEOUT] = 15;
$opt[CURLOPT_TIMEOUT] = 30;
$opt[CURLOPT_AUTOREFERER] = true;
$opt[CURLOPT_RETURNTRANSFER] = true;
$opt[CURLOPT_FOLLOWLOCATION] = true;
$opt[CURLOPT_MAXREDIRS] = 10;

if($total < $threads)
    $threads = $total;

for($i=0;$i<$threads;$i++){
    $task = curl_init(array_pop($targets));
    curl_setopt_array($task, $opt);
    curl_multi_add_handle($mh, $task);//要设置每个curl实例的属性
    unset($task);
}

$index = 1;

do{
    do{
        curl_multi_exec($mh, $running);
        if(curl_multi_select($mh, 1.0) > 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>(.*)<\/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";

1430159608673505.png

上面的代码执行效果就比较理想了,问题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依然是最好的语言 :)


@also-see 

Rolling cURL: PHP并发最佳实践

Yun_Curl_Multi/ curl_multi的用法

THX.


update @ 2016-04-16

感谢 @rainyluo 反馈一个任务提前结束的 bug。

留言交流

rainyluo
rainyluo 2016-04-15 10:55 回复
转载到rainyluo.net了 最近正好用到这一块,写的太好了.记录一下
rainyluo
rainyluo 2016-04-16 16:56 回复
最终版的代码中: if($targets){ $new_task = curl_init(array_pop($targets)); curl_setopt_array($new_task, $opt); curl_multi_add_handle($mh, $new_task);//要设置每个curl实例的属性 } 这一段貌似是不行的.因为在上面exec与select的线程中,可能会有多个返回,比如同时有两个线程返回了,这时候$running这个变量,是正在exec中的线程数量,就会减少2,但是下面只从$targets中增加一个值,这样$running 就会越来越少,最终任务没有执行完,$running 就已经变成0了. 解决方法是应该把if($targets)这一段放在for循环中,每次增加$threads-$running个新线程 就不会提前结束任务了. 数量少的时候看不出来,如果队列搞到5k就出问题了
疯狂的dabing
疯狂的dabing 2016-04-16 19:22 回复
回复 rainyluo :

感谢回复,评论有力量! 我测试队列搞到 5k 后确实发生了任务提前结束的情况。经过调试我发现问题是在添加新任务后没有发起执行,而是依赖下一轮循环自动执行。这导致一个问题:当队列剩下的所有任务在同一时间内完全,running 会变成 0,后边即便将队列填满外层的循环也会直接跳出,导致任务提前结束。同时返回 n 个任务也不要紧,因为取返回值的时候本身就是个循环,取出多少,就再加多少,问题关键是在没有执行改变running的值。最简单的复现方法是将线程设置为 2,然后执行。解决方法也很简单,在添加任务后,手动执行一次curl_multi_exec($mh, $running),从而刷新 running 的值保证外层循环不跳出。 我更新了代码,你再试试看。

点击换图