首页 > 搜索引擎开发学习 > 网络爬虫中的那些多线程设计模式

网络爬虫中的那些多线程设计模式

2012年8月12日 发表评论 阅读评论
文章作者:Yx.Ac   文章来源:勇幸|Thinking (http://www.ahathinking.com)   转载请注明,谢谢合作。

---

前天跟师兄讨论问题,提到多线程,这些天做简历,也在回顾项目,忽然想到曾经写过网络爬虫中所用到的多线程,当时就顾写了,没有好好总结,只记得细节很多,学到的东西不少,今天就爬虫中涉及到的多线程设计模式做个小整理,重点加深读写锁模式的理解。内容如下:

===问题细节说明

===网页抓取:生产者消费者模式(多v多)

===URL去重:读写锁模式

===网页写入文件:生产者消费者模式(多v一)

===关于多线程的几点注意

=========================================

问题细节说明

1)简述一下这里涉及到的三个过程:a)爬虫从待爬取URL队列中取得URL进行抓取,抓取来的网页进行解析提取新的链接加入到待爬取URL队列;b)爬取每个网页之前,程序会到已爬取URL表中查询该URL是否已经爬取过;c)网页爬取完,网页解析的内容要写入文件

上述三个过程都是在多线程的环境下进行处理,涉及到的共享资源有:URL任务队列、已爬取的URL表,存储网页内容的文件。这些共享资源的读与写都需要处理好线程的同步互斥,以保证线程安全。

2)注:爬虫中多线程的管理实际是需要维护一个线程池;URL去重也是使用MD5结合布隆过滤器进行实现的;上述所述三个过程在爬虫中并不是独立的,而是互相结合工作的,但是为了提取模式特点,我们将其分拆开,在讲述一个模式过程中如果涉及到其他模式,便略去不谈。所以,这里仅仅提取爬虫中的多线程设计模式,举例重在了解模式工作原理,不涉及爬虫具体实现的过多细节。

=========================================

网页抓取:生产者消费者模式(多v多)

1)   生产者消费者模式

学过操作系统应该对这个概念很熟悉,简单来说就是“你生产我消费”;该模式分为三个部分:生产者,消费者和产品队列(爬虫中该产品就是URL任务队列,故以下称任务队列)。

该模式可灵活应用,生产者和消费者的比例可以是:多v多,多v一,一v多;

该模式解决多线程同步问题的思想是:对任务队列加锁,队列的出队和入队操作原子性;

该模式应用到多线程爬虫:抓取线程主动去任务队列找活干,如果没活就等待,有活了就通知那些等待的抓取线程。

2)  简单示例代码

在爬虫中,生产者是网页的抓取线程,消费者也是网页的抓取线程,所以这里只要两个类就可以了,一个网页抓取线程类WorkThread,一个任务队列类TaskQueue;简单示例如下,在TaskQueue类中,使用Synchronized关键字使当前线程取得对象TaskQueue的锁,并用waitnotifyAll函数进行线程间通信

/* 任务队列类 */
public class TaskQueue
{
	private Queue<url> taskbuffer;
	private int taskcount;
	private int maxN;

	public TaskQueue(int max)
	{
		maxN = max;
		taskbuffer = new Queue<url>(maxN);
		taskcount = 0;
	}

	// 生产者调用的任务入队操作
	public synchronized void addTask(List<url> urlList)
	{
		while(maxN <= taskcount)// 如果队列够大,while语句块可以略
		{
			wait();  // 进入TaskQueue对象等待池,释放锁
		}
		while(!urlList.isEmpty())
		{
			taskbuffer.add(str);
			taskcount++;
		}
		notifyAll(); // 唤醒等待池线程
	}

	// 消费者调动的任务出队操作
	public synchronized url getTask()
	{
		while(taskcount <= 0)
		{
			wait();
		}
		url str = taskbuffer.pop();
		taskcount--;
		return str;
	}
}

/* 扮演生产者和消费者的网页抓取线程 */
public class WorkThread extends Thread
{
	private TaskQueue taskQueue;
	private List<url> urlList;

	public WorkThread(TaskQueue tq)
	{
		taskQueue = tq;
		urlList = new List<url>();
	}

	public List<url> crawl(url str)
	{
		/* crawl and parse the page str
		 * return the parse link list of page str
		 */
	}

	public void run()
	{
		try {
            while (true) {
                Thread.sleep(1000);

                url str = taskQueue.getTask(); // 扮演消费者
                urlList = crawl(str);        // 略去url去重,详见读写锁模式
                tastQueue.addTask(urlList);    // 扮演生成者
            }
        } catch (InterruptedException e) {
        }
	}
}

=========================================

URL去重:读写锁模式

1)   读写锁模式

读写锁模式简单来说就是“读写互斥,写写互斥,读读不互斥”,即大家都可以来看,但是看的时候不能写,写的时候不能看,且某一时刻只能有一个人写。

该模式分为四个部分:写入者,读者,数据对象(可读可写的类),读写锁(提供读写锁定的类);

该模式应用到爬虫中:写入者和读者就都是网页抓取线程WorkThread了;数据对象便是存储已爬取过的URL列表,一般使用Hash或Bloom Filter,后面统一称URL散列表,记为UrlLib;读写锁便是控制读写互斥机制的类,记为ReadWriteLock。

2)  简单示例代码

关于本示例代码的解读请见第三条的读写锁模式分析说明,看下面的代码是如何完成“读读不互斥,读写以及写写互斥的”,个人觉得该模式还是很有智慧的,巧妙地利用了“物理锁”产生了“逻辑锁”的效果。

/* 读写锁 */
public class ReadWriteLock
{
	private int readingReaders = 0; // (A)正在执行读取的线程数量
	private int waitingWriters = 0;  // (B)正在等待写入的线程数量
	private int writingWriters = 0;  // (C)正在执行写入的线程数量
	private boolean preferWriter = true; // 写入优先时,值为true

	// 获取逻辑读锁
	public synchronized void readLock() throws InterruptedException
	{
		while(writingWriters > 0 || (preferWriter && waitingWriters > 0))
		{
			wait();
		}
		readingReaders++;             // (A)正在执行读取的线程数加1
	}

	// 逻辑解读锁
	public synchronized void readUnlock()
	{
		readingReaders--;           // (A)正在执行读取的线程数减1
		preferWriter = true;       // 保证先来的写者能比后来的读者优先级高
		notifyAll();
	}

	// 获取逻辑写锁
	public synchronized void writeLock() throws InterruptedException
	{
		waitingWriters++;             // (B)正在等待写入的线程数加1
		try{
			while(readingReaders > 0 || writingWriters > 0)
			{
				wait();
			}
		}finally{
			waitingWriters--;        // (B)正在等待写入的线程数减1
		}
		writingWriters++;             // (C)正在执行写入的线程数加1
	}

	// 逻辑解写锁
	public synchronized void writeUnlock()
	{
		writingWriters--;            // (C)正在执行写入的线程数减1
		preferWriter = false;
		notifyAll();
	}
}

/* Url散列表类
 * 注:简单起见,下面的代码类似伪代码,不一定符合语法,例如hashtable
 */
public class UrlLib
{
	private Hashtable<url> hash;   // 存储已经爬取的Url
	private ReadWriteLock lock = new ReadWriteLock();

	public UrlLib()
	{
		hash = new Hashtable<url>();
	}

	// 读取操作:查询是否含有url
	public boolean query(url str) throws Interrupted Exception
	{
		lock.readLock();
		try{
			return hash.contains(str) ? true : false;
		}finally{
			lock.readUnlock();
		} // 使用finally保证return后还能执行解锁操作
	}

	// 写入操作
	public void add(url str) throws Interrupted Exception
	{
		lock.writeLock();
		try{
			hash.insert(str);
		}finally{
			lock.writeUnlock();
		}
	}
}

/* 扮演读者和写者的网页抓取线程 */
public class WorkThread extends Thread
{
	private TaskQueue taskQueue;
	private UrlLib urlHash;

	public WorkThread(UrlLib ul)
	{
		urlHash = ul;
	}

	public void run()
	{
		try {
            while (true) {
                Thread.sleep(1000);

                url str = taskQueue.getTask(); // 取得URL
                if(!urlHash.query(str))        // 扮演读者读取操作
                {
                	this.crawl(str);  // 如果UrlLib中没有则爬取
                	urlHash.add(str); // 扮演写者,爬取完将url写入散列表
                }
            }
        } catch (InterruptedException e) {
        	// ReadWriteLock与UrlLib中的异常统一在这里捕获
        }
	}
}

3)  读写锁模式分析说明

a)      readingReaders字段:

readingReaders在readLock方法后面递增,而在readUnlock方法前面递减,保证了readingReaders字段能够表示正在读取的线程数量(通过了readLock后,还没有通过readUnlock的线程数量),被读取的对象数据UrlLib本身并没有加锁,线程获取释放的锁是ReadWriteLock实例的锁,所以可以同时有多个线程在读取UrlLib,即一个线程经过readlock后就释放掉了锁,其他读者线程还可以继续获取,可以用下面示意图表示

b)      waitingWriters 和 preferWriter字段

waitingWriters表示调用writeLock时,就进入wait状态的线程,注意这一点很重要,一定要在线程wait之前将该字段加1,之后无论发生什么情况(该方法被打断或正常执行),该字段一定要减1,这也是try-finally的作用;

preferWriter保证在下面情景:当前有线程在读取数据(注意:读取的数据对象是没有锁的,读写锁的获取与释放与当前正读取数据的线程无关),然后先来线程1要写,一看有人在读,进入等待,又来一线程2要读,一看有preferWriter优先并且有写线程在等,所以它也进入等待,直到写完等待的条件才不成立,才有机会读;理论上我们就要保证当前所有读取数据的线程执行完毕后,先让线程1写,然后让线程2读,所以在readUnlock时设定preferWriter字段为true保证了上面我们说的情景。

如果把这两个字段去掉的话,那么程序就变成了无论写者线程何时来,只要它在等待的时候后面有读者线程来,那么它就一直得不到机会进行写入(保证不了先来后到的原则了),程序可能就会发生写者线程饿死情况,理解了上面的分析这一点就很容易理解了。

c)       物理锁与逻辑锁

该模式很好地利用了物理锁产生了一个逻辑锁,从而实现读读不互斥,但读写互斥的效果;物理锁便是java对象实例存在的一把锁,该模式中就是ReadWriteLock实例的锁,逻辑锁便是“读锁”与“写锁”;物理锁是实实在在存在的,整个过程利用的实例锁也就这一把,但是却实现了逻辑上的读锁与写锁的效果,读锁的利用也使得程序的并发性更高,使得多个线程可以同时读取数据对象。

d)      读写锁的应用场景

读写锁一般应用在读取查询较为频繁,写入不频繁的时候,其实爬虫中这部分完全也可以做成读写、读读、写写都互斥的形式,小规模情况下没啥影响,这样写就简单了很多,只要给UrlLib实例进行加锁就行了,不需要ReadWriteLock来实现逻辑锁了。

=========================================

网页写入文件:生产者消费者模式(多v一)

这一部分与第一部分类似,就不详述了,这里只是将其提出来,读写锁的彻底明白让我费了好大劲,这部分就简述了。

这里的生产者便是网页抓取线程WorkThread了,消费者是将网页内容写入文件的OutputThread,而产品队列便是存储网页对象的PageQueue类了,这里将这部分列出就是为了说明生产者消费者模式的多样化、实用性以及队列的重要性。

=========================================

关于多线程的几点注意:

1)  获得锁:synchronized关键字获得实例锁;每个实例只有一把锁,当用synchronized时一定要明白“要保护什么,获得谁的锁?”,synchronized用法常见两种:

a)    synchronized方法,执行该方法的当前线程获得当前实例对象的锁

b)    synchronized(object),该代码块开始当前线程获得object对象的锁

2)  释放锁:a)当synchronized代码块执行完毕后,释放锁;b)当执行wait时,该线程进入到当前对象的等待池,释放锁

3)  wait方法两点注意

a)    wait()方法一般或者必须放在一个while循环中,因为在多线程环境中,共享对象的状态随时可能改变。当一个线程在对象等待池中从wait状态被唤醒后,并不一定立即恢复运行,必须要等到这个线程获得了锁及CPU才能继续运行,有可能在被唤醒后而获得锁之前时,对象的状态已经发生了变化或者锁已经被其他线程获取了。

b)    wait方法一定要在synchronized的同步块代码当中才有意义,因为wait方法是进入对象等待池并释放锁,也就是说在调用wait方法时,当前的线程一定要获得对象的锁才行。

4)  sleep与wait区别:Sleep是Thread类方法,让线程停转,一段时间恢复;wait是Object类方法,用来线程间通信的,它使得当前拥有该对象的锁的进程进入等待状态并释放锁;另外,wait只有在同步块中才有意义;

(全文完)

参考资料:《java多线程设计模式

  • jim

    您好,我现在也在做网络爬虫,但是多线程总是控制不好,不知道您能否将你的网络爬虫源代码发给我一份,让我参考学习下。我保证绝对不会外传您的作品。我的邮箱地址是:jim8757@163.com。谢谢!