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

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

===问题细节说明

===网页抓取:生产者消费者模式(多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的锁,并用wait和notifyAll函数进行线程间通信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
/* 任务队列类 */
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)  简单示例代码

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
/* 读写锁 */
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只有在同步块中才有意义;

(全文完)




原文链接:http://www.ahathinking.com/archives/158.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值