2.3.1 进程同步

在多道程序环境下,进程是并发执行的,不同进程之间存在着不同的相互制约关系。

同步与异步

进程同步机制的主要任务,是对多个具有相互制约关系的进程在执行次序上进行协调,使并发执行的进程间能按照一定的规则或时序共享系统资源,实现相互合作,从而使程序的执行具有可再现性。

异步:不确定的、不可预知的

同步:确定的、可预知的、可再现的

进程间的制约关系有两种:==互斥制约==与==合作制约==。但无论哪种制约关系,在多道程序环境下,进程何时可以获得处理器,运行的速度如何,并不是进程自身可以控制的。此为进程的异步性。

  • 互斥制约: 当多个进程都对同一个临界资源进行访问时,它们之间就具有互斥制约关系。互斥制约关系中一定会出现临界资源。

    临界资源: 多个进程间需要采取互斥方式实现对某种资源的共享,那么这种资源就称为临界资源。例如,打印机、磁带机等都属于临界资源

    例如,多个进程都要访问打印机,那么它们之间就属于互斥制约关系。这多个进程对于打印请求的提交是异步的,但打印过程必须保持同步:一个进程的打印任务未完成之前,另一个进程不能进行打印

    说白了就是我用的时候,你不能用

  • 合作制约: 当某项任务需要多个进程同时协作完成时,它们之间就具有合作制约关系。

    • 例如,生产者进程在生产产品,并将产品提供给消费者进行消费。为了使生产者进程与消者进程能够并发执行,在它们之间设置了一个具有n个缓冲区的缓冲池。生产者将一个产品放入到一个缓冲区,消费者可以从一个缓冲区取走一个产品消费。此时,生产者与消费者间就是合作制约关系。
    • 所有生产者与所有的消费者都是以==异步方式==(谁先谁后不知道)运行的,但它们之间必须保特同步:不允许消费者到一个空缓冲区取产品,也不允许生产者向满缓冲区中投放产品。若缓冲池中的所有缓冲区都是空的,那么所有消费者进程阻塞,:若缓冲池中所有缓冲区都是满的,那么所有生产者进程阻塞。

异步带来的问题

多道程序环境下异步是不可避免的,但没有完善的同步机制,会产生很多无法预料的结果。

伪代码

以下是生产者与消费者共享的变量:

1
2
Item buffer[n]; //具有n个缓冲区的缓冲池
int counter=0; //当前缓冲池中具有的缓冲数据的数量,其范围是[0, n]

以下是==生产者==伪代码:

1
2
3
4
5
6
7
8
9
10
int in =0; //输入指针
void producer(){
while(true){
//生产一个Item存放到 nextp 变量中;
while(counter>=n);//若缓冲池满,则什么也不做,空循环
buffer[in]=nextp;//将nextp中的Ttem写入到当前缓冲区
in=(in+1)%n;/输入指针增-
counter++;//缓冲池中的Item数量增一
}
}

以下是==消费者==伪代码

1
2
3
4
5
6
7
8
9
10
11
int out = 0;//输出指针
void consumer(){
while(true){
whi1e(counter<=0);//若缓冲池空,则什么也不做,空循环
nextc=buffer[out];//从当前缓中区中取一个Item
out=(out+1)%n;//输出指针增-
counter--;
//缓冲池中的工tem数量减一
//消耗nextc变量中的Item;
}
}

进程中对于临界资源进行访问的代码称为临界区。例如生产者伪代码中的第8行,消费者伪代码中的第7行,都是对临界资源counter进行访问的代码,它们就是临界区。

正常情况

在缓冲池不满时,生产者生产了一个产品,同时消费者也消费了一个产品。共享变量counter在机器语言中的实现形式如下

生产者中counter加1的变化过程:

1
2
3
registerl= counter;//将counter中的数值写入到1号寄存器
register1 =registerl +1;//1号寄存器中的数值增一
counter= reigsterl;//将1号寄存器中的数值写入到counter

消费者中counter减1的变化过程

1
2
3
register2=counter;//将counter中的数值写入到2号寄存器
register2=register2-1;//2号寄存器中的数值减-
counter =reigster2;//将2号寄存器中的数值写入到counter

若分别执行生产者与消费者,无论谁先谁后,结果都是正确的,缓冲区中的Item数量counter没有变化。

异常情况

若以上6个语句在执行过程中发生了走走停停,即生产者与消费者进程的执行出现了异步性。例如,当前counter的值为5,以下是某种执行顺序

1
2
3
4
5
6
register1=counter; // register1为5
register1=register1+1; // register1为6
register2=counter; // register2为5
register2=register2-1; // register2为4
counter=reigster1; // counter为6
counter=reigster2; // counter为4

此时我们发现counter的最终结果为4。如果第5、6行换一下顺序,则结果为6。

这就是由于没有对临界资源counter进行互斥访问,即没有合理的同步机制导致了不确定性的后果。

同步机制准则

同步机制的制定需要遵循四条准则:

  • 空闲让进:若临界资源处于空闲状态,则应允许一个进程进入临界区访问临界资源
  • 忙则等待:若已有进程进入临界区访问临界资源,则其它需要访问临界资源的进程必须等待,以实现对临界资源的互斥访问
  • 有限等待:对临界资源进行等待的进程,应在有限时间内能进入临界区,以免陷入==死等==状态
  • 让权等待:若进程由于无法进入临界区而发生等待,则应立即释放处理器,以免陷入==忙等==状态

信号量机制

1965年由荷兰学者(E.W.Dijkstra)提出的信号量机制,是一种卓有成效的进程同步机制。其最常见的有三种信号量。

整型信号量

将一个用于表示资源数量的整型数S作为信号量。该信号量只能通过两个标准的原子操作wait(S)与signal(S)来访问,这两个操作分别称为P、V操作。它们的用法是:

  • 当一个进程需要申请一个资源时,可调用wait(S),即P操作
  • 当一个进程使用完毕一个资源后需要释放时,可调用signal(S),即V操作

proberen 在荷兰语中的意思为尝试 , verhogen的意思为增加or 升高

这两个源于的伪代码可以表示为

1
2
3
4
5
// P操作
wait(S){
while(S<=0){};//只要没有可用资源,当前进程就忙等,即一直执行空循环
S--;//只要有资源可用,则资源数量减一,表示申请成功一个资源
}
1
2
3
signal(S){
S++; //资源数量+1 ,表示释放了一个资源
}

Hystrix的信号量隔离

在Spring Cloud的服务熔断器Hystrix中,其执行隔离策略有两种类型:线程隔离与信号量隔离。其中信号量隔离就是“整型信号量”的应用。

记录信号量

记录型信号量是使用记录型(结构体)数据结构作为信号量,其解决了整型信号量中未遵循==让权等待==准则问题。这个结构体伪代码如下

1
2
3
4
typedef struct{
int value;//整型信号,为正时表示资源的数量,为负时绝对值表示等待该资源的进程的数量
struct process_countrol_block *list;
}semaphore;

此时的wait(S)与signal(S)也发生了变化

1
2
3
4
5
6
// P操作
wait(semaphore *S){
S->value--;//为正时表示资源数量减1,为负时绝对值表示等待该资源的进程数量增1
if(S->value<0)//若当前没有资源,
block(S->list); //调用阻塞原语将自己阻塞,并插入到阻塞队列
}
1
2
3
4
5
signal(semaphore *S){
S->value++; //为正时表示资源数量增1,为负时绝对值表示等待该资源的进程数量减1
if(S->value<=0)//若阻塞队列中有阻塞的进程
wakeup(S->list); //调用唤醒原语,从阻塞队列中唤醒一个进程
}

AND型信号量

记录型信号量机制对于同时需要获取到多个共享资源的情况,可能存在死锁的风险。AND型信号量机制解决了这个问题。

在多道程序环境中,多个进程可以竞争有限数量的资源。当一个进程申请资源时,如果这时没有可用资源,那么这个进程进入等待状态。有时,如果所申请的资源被其他等待进程占有,那么该等待进程有可能再也无法改变状态。这种情况称为死锁

AND信号量同步机制的基本思想是:将进程在整个运行过程中需要的所有资源,一次性全部分配给进程,等进程使用完后再一起释放。只要有一个资源未能分配给进程,其它所有可能为之分配的资源也全部不能分配给它。

AND信号量机制中的wait(S)与signal(S)表示为了Swait(S)与Ssignal(S)。

1
2
3
4
5
6
7
8
9
10
11
Swait(S1,S2,...,Sn){
while(true){
if(S1>=1&..&&Sn>=1){//若所有资源都可用
for(i=1;i<=n;i++)//则全部资源数量减1
si--; //当前遍历资源Si数量减1
break;
}else{//若存在不可用资源
将进程放入第一个不可用资源的阻塞队列;
}
}
}
1
2
3
4
5
6
7
Ssignal(S1,S2,...Sn){
while(true)
for(i=1;i<=n;i++){//释放所有资源
Si++; //当前遍历的资源Si数量增1
将s资源阻塞队列中的进程唤醒;
}
}

信号量集

对于AND型信号量机制,其每次PV操作只能申请或释放1个资源,如果需要申请或释放多个资源时需要执行多次的PV操作,不仅低效,而且还存在发生死锁的风险。信号量集就是用于解决这些问题的。

对于信号量集需要理解以下两点:

  • 信号量集Swait(S1,t1,d1,Sn,tn,dn)中,t1表示S1资源分配的下限值,即当S1资源的数量小
    t1时将不予以分配,发生阻塞;d1表示在允许分配的前提下一次分配的s1资源的数量。
  • 信号量集Ssignal(S1,d1,Sn,dn)表示一次性释放d1个S1资源,.,一次性释放dn个Sn资源。

对于信号量集,需要注意以下几种特殊用法

  • Swait(S1,1,1 …, Sn,1,1):其就是AND型信号量Swait(S1,…, Sn)。
  • Swait(S,1,1):其就是记录型信号量wait(S)。
  • Swait(S,1,0):当资源S的数量小于1时发生阻塞,否则允许通过。一般用作开关

管程机制

虽然信号量机制是一种方便、有效的进程同步机制,但每个要访问临界资源的进程都必须自备同步PV操作。这不仅使得PV操作大量分散在了进程之中,而且如果使用不当(没有成对出现)就会导致系统死锁。管程机制解决了这些问题。

管程定义

管程是一种==面向对象思想==的体现。其由一组==共享数据结构==及对这些数据的==一组操作==构成。以下是不同的学者给出的两种不同的管理定义:

  • 代表共享资源的数据结构以及“由对该共享数据结构实施操作的一组过程所组成”的资源管理程序共同构成的一个操作系统的资源管理模块,我们称之为管程。
  • 一个管程定义了一个数据结构和能为并发进程所执行(在该数据结构上)的一组操作,这组操作能同步进程和改变管程中的数据。

管程结构

image-20221219154820237

根据以上描述及管程的定义可知,管程由四部分构成:

管程名称:不同共享资源及不同的操作可以构成不同的管程,不同的管程对象具有不同的名称。

共享数据结构:这些共享数据结构是局部于管程之中的,用于描述共享资源。

一组操作:这组操作用于操作共享数据结构。进程访问共享资源,即共享数据结构,只能通过这组管程内的操作完成。对临界资源的互斥访问就是通过这组操作完成的。

初始化代码:是对局部于管程的共享数据进行初始化的代码。只在管程对象被创建时执行一次

管程同步

管程由请求和释放共享资源的进程调用。通过管程机制可以实现进程间对共享资源(共享数据结构)的同步访问

  • 当某进程通过管程访问临界资源时,管程便调用条件x的wait原语cwait(x),若条件x未能满足,便使该进程阻塞,并将其排在条件x的阻塞队列上。
  • 当某进程通过管程访问完毕临界资源时,其同时会改变条件x,即调用条件x的signal原语csignal(x),从条件x阻塞队列中唤醒一个进程。

也就是说,在管程中还需要具有用于实现进程同步访问的条件变量。

经典进程同步问题

生产者-消费者问题

生产者-消费者问题是典型的合作制约关系的进程同步问题。该问题的描述是:生产者与消费者具有一个公共缓冲池,该缓冲池包含个缓冲区。每个缓冲区中可以存放一个数据(每个缓冲区只有两种状态:有数据便为满状态,无数据便为空状态)。只要缓冲池中存在空缓冲区,生产者就可将数据写入缓冲池,一旦缓冲池全满,生产者就要停止生产;只要缓冲池中存在满缓冲区,消费者就可从缓冲池中取走一条数据,一旦缓冲池全空,消费者就要停止消费。
下面使用不同的进程同步机制来实现“生产者-消费者模型”中的同步问题。

记录型信号量

以下是生产者与消费者共享的变量

1
2
3
4
Item buffer[n];//缓冲池
semaphore mutex = 1; //实现对缓冲池互斥访问的信号量
semaphore empty = n; //缓冲池中空缓冲区信号量
semaphore full = 0; //缓冲池中满缓冲区信号量
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
//以下是生产者伪代码:
int in=0; //输入指针
void producer(){
while(true){
生产一个Item存放到nextp变量中;
wait(empty); //信号量empty-1=>空缓冲区数量减少
wait(mutex); //互斥信号量-1=>第一个进程可以进入临界区,其他进程阻塞
buffer[in]= nextp; //临界区,对临界资源互斥访问
in= (in +1)%n;
signal(mutex); //释放临界资源,从阻塞队列中唤醒一个进程
signal(full); //信号量full+1 ,满缓冲区数量增加1
}
};

//以下为消费者伪代码
int out =0;//输出指针
void consumer(){
while(true){
wait(ful1);//信号量fu11减1,即满缓冲区数量减沙1个
wait(mutex);//互斥信号量减1。即第l个进程可进入临界区,其它进程阻塞
nextc=buffer[out];//临界区,对临界资源互斥访问
out =(out+1)%n;
signal(mutex);//释放临界资源,从阻塞队列中唤醒一个进程
signaL(empty);//信号量emptyi增1,即空缓冲区数量增加1个
消费nextc变量中的Item;
}
};

//以下为入口函数伪代码
void main()
{
producer();
consumer();
}
AND型信号变量

以下是生产者与消费者共享的变量:

1
2
3
4
Item buffer[n]; 
semaphore mutex = 1;
semaphore empty = n;
semaphore full = 0;

以下是生产者伪代码

1
2
3
4
5
6
7
8
9
10
int in = 0;
void producer(){
while(true){
生产一个Item存放到nextp变量中;
Swait(empty, mutex); //一次申请多个资源:一个空缓冲区与临界资源
buffer[in]= nextp; //临界区
in = (in +1)% n;
Ssignal(mutex, full); //一次释放多个资源:临界资源与满缓冲区
}
};

以下为消费者伪代码

1
2
3
4
5
6
7
8
9
10
int out = 0;
void consumer(){
while(true){
Swait(full, mutex); //一次申请多个资源:一个满缓冲区与临界资源
nextc = buffer[out]; //临界区
out = (out +1)% n;
signal(mutex, empty); //一次释放多个资源:临界资源与空缓冲区消费nextc变量中的Item;
}
};

以下为入口函数伪代码

1
2
3
4
5
void main(){
producer();
consumer();
}

管程机制

下面定义了一个管程ProducerConsumer的对象pc

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
Monitor ProducerConsumer {	//定义一个管程ProducerConsumer
// buffer[]与count用于描述临界资源,需要互斥访问
Item buffer[n]; //共享数据:缓冲池
int count; //共享数据:表示当前缓冲池中缓冲数据的数量,其范围是[0, n]5
int in; //输入指针
int out; //输出指针

condition cEmpty; //条件变量:缓冲池中具有空缓冲区
condition cFull; //条件变量:缓冲池中具有满缓冲区

//初始化共享数据
{
in = 0;
out = 0;
count = 0; //说明当前缓冲池为空
}

//定义一个向缓冲池中写入一个数据的过程
public void put(Item item){
// put过程需要满足缓冲池中具有空缓冲区的条件
//若当前缓冲池全满,则阻塞当前进程
if(count >= n) wait(cEmpty);
buffer[in]= item; //临界区
in = (in+1)% n;
count++;
signal(cFull); // put过程结束后满缓冲区数量增1,同时会唤醒一个进程28
}

//定义一个从缓冲池中获取一个数据的过程
public Item get(){
// get过程需要满足缓冲池中具有满缓冲区的条件
//若缓冲池全空,则阻塞当前进程
if(count <= 0) wait(cFull);
Item item = buffer[out]; //临界区
out = (out+1)% n;
count--;
signal(cEmpty); // get过程结束后空缓冲区数量增1,同时会唤醒一个进程
return item;
}
} pc; //声明了一个管程ProducerConsumer的实例pc

以下为生产者伪代码:

1
2
3
4
5
void producer(){
while(true){
生产一个Item存放到nextp变量中; pc.put(nextp);
}
}

以下为消费者伪代码:

1
2
3
4
5
6
void consumer(){
while(true){
Item nextc = pc.get();
消费nextc变量中的Item;
}
}

以下为入口函数代码

1
2
3
4
5
void main(){
producer();
consumer();
}

哲学家进餐问题

哲学家进餐问题是典型的==互斥制约关系==的进程同步问题。该问题的描述是:有五个哲学家在同一张圆桌上用餐与思考。圆桌上为每人配发了一只碗,但仅在他们的每个空隙中配发了一只筷子,也即只有五只筷子可用。要求他们交替进行用餐与思考。平时大家都在思考,当某个哲学家饥饿时会试图去取其左右两边离他最近的筷子。只有在拿到两只筷子时才能进餐,否则只能继续思考。

进餐完毕,放下筷子开始思考。

下面使用不同的进程同步机制来实现“哲学家进餐模型”中的同步问题。

记录型信号量

为了实现对临界资源筷子的互斥使用,指定一个筷子就代表一个信号量,五根筷子构成由五个信号量组成的信号量数组,每个信号量均被初始化为了1。

第i位哲学家的活动可描述为:

1
2
3
4
5
6
7
8
while(true){
while(没有感觉饥饿){
思考一个问题;
}
Swait(chopstick[i], chopstick[(i+1)%5]); //申请两支筷子
开始进餐;
signal(chopstick[i], chopstick[(i+1)%5]); //释放两支筷子
}

每个哲学家在饥饿时都会先拿右手边的筷子,再拿左手边筷子,都拿到后就可以进餐了。进餐完毕,其也是先放右手边筷子,再放左手边筷子。该方式不会出现两个相邻哲学家同时进餐的情况。

但该方式可能会引发死锁:若5位哲学家同时饥饿,同时去拿右手边筷子,再去拿左手边筷子时就会由于没有筷子可拿而被阻塞。

AND型信号量

仅当哲学家的两根筷子都可用时,才允许其拿直筷子进餐。

第i位哲学家的活动可描述为:

1
2
3
4
5
6
7
8
while(true){
while(没有感觉饥饿){
思考一个问题;
}
Swait(chopstick[i], chopstick[(i+1)%5]); //申请两支筷子
开始进餐;
signal(chopstick[i], chopstick[(i+1)%5]); //释放两支筷子
}
管程机制

将哲学家定义为一个管程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Monitor Philosopher{//定义一个管程Philosopher
semaphore chopstick[5]; //共烹数据结构
//初始化代码
{
chopstick[5]={1,1,1,1,1};
}
//定义一个哲学家进餐过程
public void eat(){
Swait(chopstick[i], chopstick[(i+1)%5]); //申请两支筷子
开始进餐;
signal(chopstick[i], chopstick[(i+1)%5]); //释放两支筷子
}

//定义一个哲学家思考过程
public void think(){
while(没有感觉饥饿){
思考一个问题;
}
}
} pp; //声明一个管程对象pp

一个哲学家进程的入口函数伪代码:

1
2
3
4
5
6
void main(){
while(true){
pp.think();
pp.eat();
}
}

读者-写者问题

读者-写者问题是互斥制约与合作制约双重关系的进程同步问题。该问题的描述是:一个被多个进程共享的文件、记录或数据结构,允许进程对其执行读、写操作。读进程称为读者,写进程称为写者。其允许多个进程同时读取,但只要有一个进程在读,就不能有进程对其进行写操作。同样,只要有一个进程在写,其它进程的读、写操作都不允许。

读者*-写者问题与生产者-*消费者问题有很多相似的地方:

  • 这两种问题模型都是在描述两种异构进程间的同步关系:生产者与消费者,读者与写者这两种异构进程分工不同:一个充当写角色(生产者、写者),一个充当读角色(消费者、读者)
  • 这两种模型中都有异构进程可共同操作的共享数据结构
  • 对共享数据结构的操作都是:在写时不能读,在读时不能写

读者*-写者问题与生产者-*消费者问题的区别主要体现在一处:

  • 生产者*-*消费者问题中,多个消费者进程对于共享数据结构的读取操作是互斥的,即只要有一个进程在读,其它进程也是不能读的

读者*-*写者问题中,允许多个读者进程同时读取

2.3.2 分布式同步

如果实现同步的进程分布在不同的服务器中,那么此时的进程同步需要用到分布式技术。可以实现分布式同步的分布式技术很多,例如Redis、Zookeeper等中间件。

什么是分布式同步

分布式同步,也称为分布式协调,是分布式系统中不可缺少的环节,是将不同的分布式组件有机结合起来的关键。对于一个在多台机器上运行的应用而言,通常需要一个协调者来控制整个系统的运行流程,例如执行的先后顺序,或执行与不执行等,这种控制机制就称为分布式同步。

下面就以Zookeeper实现分布式同步为例来演示一下实现原理与过程。

Zookeeper中的节点类型:

  • 持久节点
  • 持久顺序节点
  • 临时节点:其生命周期与客户端的会话绑定在一起,会话消失则该节点就会被自动清理。临时节点只能作叶子节点,不能创建子节点
  • 临时顺序节点

分布式日志收集系统

系统组成

首先要清楚,分布式日志收集系统由四部分组成:日志源集群、日志收集器集群,zk集群,及监控系统。

Zookeeper在该系统中用于协调与同步日志源集群与日志收集器集群中各个主机的配对关系。即由于扩容、缩容、宕机、网络等问题,引发日志源集群或日志收集器集群中主机数量变化时,能够同步实现日志收集器集群中的任务再分配。

系统工作原理

分布式日志收集系统的工作步骤有以下几步:

  1. 收集器的注册
    • 在zk上创建各个收集器对应的节点。
  2. 任务分配
    • 系统根据收集器与生成器的数量,将所有日志源集群主机分组,分别分配给各个收集器。
  3. 状态收集,这里的状态收集指的是两方面的收集:
    • 日志源主机状态,例如,日志源主机是否存活,其已经产生多少日志等
    • 收集器的运行状态,例如,收集器本身已经收集了多少字节的日志、当前CPU、内存的使用情况等
  4. 任务再分配Rebalance
    • 当出现收集器挂掉或扩容,就需要动态地进行日志收集任务再分配了,这个过程称为Rebalance。当zk 检测到有收集器宕机,或发现有新的收集器加入时,系统就会进行任务再分配。有两种Rebalance方案:
      1. 全局动态分配
      2. 局部动态分配

MySQL数据复制总线

数据复制总线组成

MySQL数据复制总线是一个实时数据复制框架,用于在不同的MySQL数据库实例间进行异构数据复制。其核心部分由三部分组成:生产者、复制管道、消费者

那么,MySQL数据复制总线系统中哪里需要使用zk的分布式同步功能呢?以上结构中可以显示看到存在的问题:replicator存在单点问题。为了解决这个问题,就需要为其设置多个热备主机。那么,这些热备主机是如何协调工作的呢?这时候就需要使用zk来做协调工作了,即由zk来完成分布式同步工作。

数据复制总线工作原理

MySQL复制总线的工作步骤,总的来说分为三大步:

  1. 复制任务注册:复制任务注册实际就是指不同的复制任务在zk中创建不同的znode,即将复制任务注册到zk中。

  2. replicator热备:复制任务是由replicator主机完成的。为了防止replicator在复制过程中出现故障,replicator采用热备容灾方案,即将同一个复制任务部署到多个不同的repicator主机上,但仅使一个处于RUNNING状态,而其它的主机则处于STANDBY状态。当RUNNING状态的主机出现故障,无法完成复制任务时,使某一个STANDBY状态主机转换为RUNNING状态,继续完成复制任务。

    那么各个replicator的状态是从哪里来的?是从zk中读取的。

  3. 主备切换:当RUNNING态的主机出现宕机,则该主机对应的子节点马上就被删除了,然后在当前处于STANDBY状态中的replicator中找到序号最小的子节点,然后将其状态马上修改为RUNNING,完成“主备切换”。

分布式FIFO队列

Zookeeper可以实现对生产者生产出的数据的顺序消费,也就是实现对消费者消费的同步控制。即使用Zookeeper实现一个FIFO的队列。其设计思路是:利用顺序节点的有序性,为每个数据在zk中都创建一个相应的节点。然后为每个节点都注册watcher监听。一个节点被消费,则会引发消费者消费下一个节点,直到消费完毕。

进程通信是指进程间的信息交互。根据通信双方通信的实现方式可以分为四大类。

  • 共享存储器通信
  • 管道通信
  • 消息传递通信
  • C/S通信

2.3.3 共享存储器通信

相互通信的进程共享数据结构存储区,进程间通过这些空间进行通信。据此,又可分为两种:

共享数据结构的通信

该方式通常是同一主机中不同进程间的通信方式。例如生产者-消费者问题中的缓冲池就属于这种方式。操作系统仅提供用于存储共享数据结构的存储空间,对共享数据结构的创建与同步处理,则是由程序员自己负责的。这种方式仅适合少量同构数据的传递

共享存储区的通信

为了传输大量、异构数据,可以划分出一块所有进程都可访问的共享存储区域,通过该区域进行进程间的数据交换。

其原理图可以直接使用Dubbo官网中的架构图

2.3.4 管道通信

所谓管道,即Channel,就是用于连接一个读进程和一个写进程、实现两个进程间通信的共享文件,又称为pipe文件。

管道通信机制提供了三方面协调能力:

  • 互斥:当一个进程正在对pipe执行读/写操作时,另一个进程必须等待。
  • 同步:进程试图读空管道时,在有数据写入管道前,进程将一直阻塞。同样,管道已经满时,进程再试图写管道,在其它进程从管道中移走数据之前,写进程将一直阻塞。
  • 双方:只有确定了通信的对方存在时才能进行通信。

管道通信属于半双工通信。

  • 单工通信
  • 全双工通信
  • 半双工通信

Netty的全双工管道通信

Netty中的Client与Server是通过全双工管道进行通信的:Client与Server中都创建了用于通信的Channel,且只要对方将数据写入到了Channel,自己就可以通过channelRead()方法将数据读取。

2.3.5 消息传递通信

消息传递机制中,进程无需借助任何共享数据结构或共享存储区域,而是以格式化的消息为单位,将通信的数据封装在消息中,并利用操作系统提供的一组通信命令(原语),在进程间进行消息传递。

这种通信机制在生产中使用很广:

异构系统中一般使用 JSON格式的消息通信。

同构系统中一般会使用更加符合自身业务需求的消息格式通信。例如,Spring Cloud的微服务间通过RESTful进行通信。再如,Dubbo中消费者、提供者间,及注册中心,这三者的通信全部是通过URL进行通信的。

Dubbo中的URL通信

Dubbo中服务提供者向注册中心Zookeeper中的注册,实际就是将代表提供者的URL信息写入到Zookeeper中相应的服务名称节点之下。即提供者与注册中心间是通过URL进行通信的。只要提供者注册到注册中心中的是URL,就意味着,消费者也只能通过URL与注册中心通信,从注册中心将代表提供者的URL信息通过URL带回。

2.3.6 C/S通信

C/S通信机制更适合网络环境下进程间的通信。其实现方式有两种

Socket通信

Socket通信,即套接字通信。一个套接字就是一个通信标识类型的数据结构,包含了通信目的地址、端口号、通信协议等,当然还包括真正要进行网络传输的数据。网络通信技术BIO、NIO、AIO的底层就是Socket。

RPC通信

RPC,Remote Procedure Call,远程过程调用,是一种通信协议,或者说是一种通信模型。RPC通信会使一台主机上的进程调用另一台主机上的进程,使用起来就像调用本地进程一样,无需额外的操作。

Zookeeper中的NIO通信

Zookeeper中Client与Server就是通过NIO进行通信的,即本质为socket通信。

Client在获取到Server的列表后,首先对Server列表进行Shuffle,然后再以轮询方式获取到一个由host 与port构成的地址,然后再根据host获取到其对应的所有ip,再对这些ip进行Shuffle,获取Shuffle后的第一个地址。此时Client会通过NIO与该地址的Server进行连接尝试,直到连接成功,或不满足重试策略。

Netty中的NIO通信

NettyClient与NettyServer间的通信是全双工通道通信,因为它们都向彼此创建了一个通道Channel,并且都是NIO的Channel。只不过客户端是NioSocketChannel,服务端是NioServerSocketChannel。即它们的底层通信采用的都是NIO通信,即Socket通信。

Dubbo的RPC底层是Netty

Dubbo的提供者Provider在启动时,会为其每个服务暴露协议所暴露的每个服务创建一个NettyServer,以等待消费者的调用;而Dubbo的服务消费者在使用相应服务协议进行服务消费时,会为该消费者创建指定个数的长连接,每个长连接对应一个NettyClient。它们连接着相应服务的NettyServer,即这些长连接就是这些NettyClient与NettyServer间的长连接。

一致性通信模型

一致性通信模型,是为了实现数据一致性目的而构建的一种通信模型。为了了解“数据一致性”,先来了解一下“拜占庭将军问题”。

拜占庭将军问题

拜占庭将军问题是由Paxos算法作者莱斯利·兰伯特(Leslie Lamport)提出的点对点通信中的基本问题。该问题要说明的意思是,在不可靠信道上试图通过消息传递的方式达到一致性是不可能的。

两种通信模型

一般情况下,分布式系统为实现节点中数据的一致性,各节点间通常采用两种一致性通信模型:星形通信模型与网状通信模型。无论哪种模型,其实现一致性的前提是,不存在拜占庭将军问题,即信道是安全的、可靠的,集群节点间传递的消息是不会被篡改的。paxos算法采用的网状通信模型。

Reactor通信模型

在高性能的网络通信设计中,有两个比较著名的网络通信模型Reactor和Proactor模式,

其中Reactor模式属于同步非阻塞I/O的网络通信模型,

而Proactor运属于异步非阻塞I/O的网络通信模型。

Reactor单线程模型

Reactor 单线程模型,指的是当前的主机会为每一个通信对端形成一个Channel,而所有这些Channel 都会与一个线程相绑定,该线程用于完成它们间的所有通信处理。

就绪事件分为四类:

连接就绪:SelectionKey.OP_CONNECT
接收连接就绪:SelectionKey.OP_ACCEPT
读就绪:SelectionKey.OP_READ
写就绪:SelectionKey.OP_WRITE

Reactor线程池模型

Reactor单线程模型中使用一个线程处理所有通信对端的所有请求,在高并发场景中会严重影响系统性能。所以,就将单线程模型中的这一个线程替换为了一个线程池。大大提高了系统性能。

Reactor多线程池模型

若在高并发场景下(请求连接的并发量是数以百万计的),且IO操作还比较耗时,此时的Server即使采用的是Reactor线程池模型,系统性能也会急剧下降。此时,可以将连接操作与IO操作分开处理,形成Reactor的多线程模型。

当客户端通过处理连接请求的Channel连接上Server后,系统会为该客户端再生成一个子Channel专门用于处理该客户端的IO请求。这两类不同的Channel连接着两类不同的线程池。而线程池中的线程数量,可以根据需求分别设置。提高了系统性能。

Netty中的Reactor

NettyServer与NettyClient都是采用了Reactor通信模型与客户端进行通信,但并不完全相同。

Netty-Server采用了多线程模型。不过线程池是由EventLoopGroup充当。EventLoopGroup中的每一个EventLoop都绑定着一个线程,用于处理该Channel与当前Server间的操作。一个Channel只能与一个EventLoop绑定,但一个EventLoop可以绑定多个Channel。即Channel与EventLoop间的关系是n:1。所以每一个EventLoop就需要一个Selector,用于选择其来处理哪个channel中的请求。

Netty-Client采用的是线程池模型。因为其只需要与Server连接一次即可,无需区分连接请求与IO请求。

Proactor通信模型

由于Reactor模式属于同步非阻塞I/O的网络通信模型,其同步性导致其处理高耗时IO时性能会较低。于是就产生了异步非阻塞I/O的网络通信模型Proactor。

Reactor模型的IO

下面以网络IO中的读操作请求为例来分析整个执行过程

当IO调用线程(主线程)发起了read()调用后,系统会创建一个Channel,并会将其注册到Selector,并注册“读就绪”事件OPS_READ,然后该线程会不停的查看User Buffer中是否有了数据。即IO调用线程在等待IO期间并未发生阻塞,所以Reader模型属于非阻塞IO。

当selector接收到这个注册后,其就会不停的查看该Channel所关联的网卡缓存中是否具有了数据。当Selector发现到该网卡缓存中具有了数据后,该读操作就绪。此时与该Selector绑定的线程,即IO执行线程,会发起system call,将网卡缓存中的数据读取到User Buffer。

当User Buffer中出现数据后,会被IO调用线程发现,其会使用User Buffer中的数据继续执行read()后面的逻辑。即IO后面逻辑的执行是在IO完成后才进行的,所以Reactor模型属于同步IO,即Reactor模型属于“同步非阻塞IO”模型。

Proactor模型的IO

下面仍以网络IO中的读操作请求为例来分析整个执行过程。

当IO调用线程(主线程)调用了异步的read()操作后,系统会创建一个Channel,并会将其注册到Proactor实例,并注册“读结束”事件ReadComplete,然后该线程会继续执行read()后的逻辑而不会等待 IO的完成。所以,Proactor模型属于“异步非阻塞IO”模型。

在Proactor模型中,一个Channel的所有IO操作共享一个Proactor实例。或者说,一个Proactor实例处理同一Channel中所有的IO请求。这个Proactor实例是在Channel创建时完成的初始化。每个Proactor 实例具有一个绑定的线程,用于执行相关IO操作。这个绑定的线程就是IO执行线程。

当Proactor实例接收了read()操作的注册后,其会为网卡缓存注册一个监听。若网卡缓存中有了数据,则马上通过DMA将数据写入到User Buffer中。一旦数据写入User Buffer完成,则该IO操作完毕,产生ReadComplete事件。此时会将该事件写入到Proactor所维护的一个队列。Proactor实例会将队列中的事件逐个发送给各个IO调用者线程,触发这些IO调用者线程相应的回调。

对比

Proactor的异步性使其并发处理能力要强于Reactor Proactor在处理高耗时IO时性能要高于Ractor Proactor的实现逻辑复杂,其编码成本较Ractor高很多Proactor的异步性依赖于OS对异步的支持