https://crazydb.com/archive/php%E7%9A%84%E5%A4%9A%E7%BA%BF%E7%A8%8Bcurl_multi 疯狂的dabing   2015-04-27
这篇日志发布时间已经超过一年,许多内容可能已经失效,请读者酌情参考。

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

当返回值为-1时,并不意味着这是一个错误,只是说明select时没有并没有完成excute,描述给的建议是不要执行select等阻塞操作,立即exec。

但是在libcurl的7.20版本之后,不再使用这个返回值了,原因是这个循环libcurl自己做了,就不再需要我们手动循环了。

同时注意curl_multi_select,其实还有第二个参数timeout,根据语焉不详的手册,这货应该是自带阻塞,所以就不再需要手动sleep了。

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

然后就遇到了问题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("/(.*)/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函数,这个函数手册是这么说的:

擦他大爷,这中文翻译绝壁是机翻,对我造成了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依然是最好的语言 :)


01-16 15:05