并发处理中的问题以及解决这些问题的并发模型

单机并发是集群并发的基础。本文主要将单机并发问题,和解决这些单机并发问题的解决模型。本文只讨论单机并发,集群并发将在我的后续其他文章中讨论,所以本文将单机并发简化称为并发,省去单机二字。

1. 并发问题

什么并发问题,举个例子,一个服务器,有大量的链接上来,每个链接同时发请求。另外一种情况,只有一个链接到服务器,但这个链接短时间内发送大量的请求。有些人只是把第一种场景称之为并发,这种场景多是直接面向用户的,比如web服务器,但是第二种场景也是并发,比如SOA架构中的服务。

这两种的并发是有区别,而且有很多种方式来实现解决,这里可以参看我的关于IO模型的讨论。但是这里我们采用一种统一的方式来处理,即将每个链接上的请求放入一个队列,如何高效的将所有请求放入队列可以参考IO模型的讨论。这是一种非常常见的处理方式,大多数服务器和服务框架都采用这种方式。

从队列中取出请求进行计算处理是并发的另外一部分,本文讨论的就是这一部分。所以并发就是同时处理多个请求,如何提高同时处理请求的数量就是并发问题。

提高并发首先要知道我们要解决哪些问题,并发问题隐含以下3个问题:

  • 1.多路执行问题。
  • 2.多路间的通信问题。
  • 3.调用问题(包括耗时阻塞调用问题,并且调用存在多个,之间存在复杂的串并行关系)。

1.1 什么是多路执行问题

要提高并发能力最基本的方式就是同时多路执行。多路执行是逻辑上的概念,往简单里说就是多进程执行或者多线程执行等。这是往简单里说,实际上多路执行是一个比较复杂的问题,后续会有解释。

1.2 什么是多路间的通信问题

有了多路执行,但是每一路执行都不是孤立,比如都需要一些共同的数据,或者一路的执行需要另一路执行提供数据。那么这就需要多路间的通信。比如,如果是多进程方式的多路执行,就是进程间通信问题。

1.3 什么是调用问题

并发问题并不是一个单纯独立的问题,实际的并发问题往往是一个很复杂的问题。比如网络服务,提高一个网络服务的并发能力,这个网络服务接收到一个请求后还要请求其他网络服务才能完成这个请求,请求其他网络服务往往是一个耗时的阻塞调用。要提高并发能力,就需要解决耗时阻塞调用问题。并且在实际问题中,这些调用有可能存在多个,并且多个调用间可能还存在复杂的串并行关系。

下面会讨论如何解决这些问题,并且针对这些问题总结出问题解决模型。三个问题各自有各自的解决模型。

2. 多路执行问题的解决模型

2.1 两种模型

解决多路执行问题的模型有2个:

  • 多线程/进程(即物理线程/进程)模型
  • 用户态线程/轻量级线程(进程)模型

实现多路执行,自然想到的就是使用多进程或者多线程来实现,这也是最常见的一种解决模型。这种模型中的线程和进程是物理线程和物理进程,"物理"是指操作系统的提供的。一个物理线程/进程就是一路执行。各种语言都会实现这种模型。

我们也可以使用一个物理线程实现多路执行,即物理线程在不同的执行间进行切换。一般来讲,这种同一个物理线程里的不同执行会被称为轻量级线程,即在一个物理线程中模拟出多个用户态线程或者叫轻量级线程。想对于物理线程,这种轻量级线程,不需要操作系统做切换,即切换时不需要从操作系统的用户态转入的内核态,所以这种轻量级线程,也叫做用户态线程。在erlang中,也有轻量级的概念,但是erlang是轻量的进程,但本质上和轻量级线程是一样的。这三种叫法:用户态线程,轻量级线程,轻量级进程,本质上来讲是一样的,所以本文后续只用用户态线程这种叫法。

2.2 用户态线程模型的具体说明

2.2.1 用户态线程的实现方式

用户态线程是通过切分物理线程的方式实现的。
用户态线程的本质就是切分物理线程。

不同的语言实现用户态线程的方式不同:

  • C/C++中的用户态线程

    C/C++中是通过协程技术实现的用户态线程,详细的描述可以参看我关于c++协程的文章。
    
  • erlang中的用户态线程

    erlang中有轻量级进程的概念,是基于虚拟机实现的。
    
  • go中的用户态线程

    go语言中的用户态线程叫做goroutine,是基于协程技术实现的
    
  • scala中的用户态线程

    scala中的用户态线程叫做actor
    
  • Java中的用户态线程

    Java可以采用第三方库实现用户态线程,详细的描述可以参看我关于Java并发的文章。
    

2.2.1.1 用户态线程与协程的关系

可以看到很多语言中的用户态线程都采用协程技术,但是协程并不等价与用户态线程,用户态线程也是基于协程实现的,刚刚我们说了用户态线程的本质是线程切分。

比如scala中的actor,因为scala是也基于java的底层库,Java中是没有内置支持协程的,所以actor的实现就不是基于协程的。scala中acotor的实现类似于这样的实现:

通常都是建立任务队列,1个线程或多个线程,或者线程池,从队列中取出并且执行这些任务,每个任务相当与一个actor。

在这样的实现中任务是顺序执行的,也就是说任务不能在中途切换到另一个任务。协程技术可以实现在任务的中途切换到另一个任务。也就是说协程的本质可以说是一种用户态线程的切换机制。详细的关于协程的描述可以参看我关于协程的文章。

2.2.2 用户态线程调度策略

用户态线程有三种调度策略:

  • 1.顺序的调度策略(在只实现了任务队列+线程池的实现方式中,任务是顺序执行的,比如,scala的actor)
  • 2.协作式线程调度,基于协程的用户线程切换
  • 3.轮询的调度策略(erlang的轮转调度策略,go 1.2后具有简单抢占机制的调度策略)

2.2.3 用户态线程抽象模型

无论用什么方法实现用户态线程,最终都需要通过物理线程实现。所以用户态线程一定和物理线程有一定的对应关系,也即用户态线程抽象模型,抽象模型包括三种:
1:1
N:1
n:m

1:1
即一个物理线程抽象成一个用户态线程
N:1
一个物理线程模拟N个用户态线程
N:M
m个物理线程模拟n个用户态线程,比如go中的pmg的概念,更复杂的3元抽象

3.阻塞调用问题的解决模型

解决阻塞调用问题,通常有三种方式,也即有三种解决模型:

  • 1.多线程模型(保持阻塞用多线程规避)
  • 2.回调模型
  • 3.协程切换模型

第一种模型的解决方式是,遇到阻塞调用时,保持阻塞调用,也即不做任何处理,而是采用启动多个线程的方式提高并发能力。
第二种回调模型,采用异步技术,将阻塞调用变成异步调用,当调用完成时通过回调的方式通知调用者。这样一个线程就可以同时处理多个并发请求。这种模型是一种最高效的模型。避免的过多的物理线程频繁切换带来的不必要的开销。
第三种协程切换模型,也采用异步技术,将阻塞调用变成异步调用,所以从效率上来讲,这种模型的效率并部高于第二种模型,为什么会出现这种模型,是因为第二种回调模型编程复杂。协程切换模型简化了这种复杂性。

通常这三种模型被总结成三种并发模型,网上对并发模型的分类: 多线程模型,异步回调模型,轻量级线程/协程模型。

我们继续文章最开始的例子,请求被放入一个队列中,我们分以下接个讨论,看看如何并发处理这些队列。在处理实际请求时,可能需要访问数据库,调用其他服务等等,这些操作都可以简化成,向网络发送一个请求,再从网络接收一个回复。

3.1 多线程模型示例

伪代码如下:

main_processing()
{
    loop
    {
        request = queue.get();
        create_thread (handle_request, request);
    }
}

handle_request(request)
{
    sendto(server1);
    receivefrom(server1);
    
    sendto(server2);
    receivefrom(server2);
    
    sendto(server3);
    receivefrom(server3);
}

在请求处理函数中,receivefrom()是阻塞调用。

3.2 回调模型示例

这种模型中引入了异步技术。

非常简略的示意伪代码:

request_process_loop()
{
    loop
    {
        if (!queue.empty())
        {
            request = queue.get();
            handle_request(request);
        }
    }
}

handle_request(request)
{
    sendto (server1, request, handle_request_callback1);
}

handle_request_callback1(response)
{
    sendto (server2, request, handle_request_callback2);
}

handle_request_callback2(response)
{
    sendto (server3, request, handle_request_callback3);
}

handle_request_callback3(response)
{
    print (response.message);
}

在这种模型中send是异步的,并且传入了一个回调函数,不需要显示调用recieve,系统在收到response时,会调用回调函数。这里省略了采用IO复用机制,调用callback函数的细节。具体的技术细节参看IO模型。
这种模型种不会引入过多线程,一般一个线程就可以处理并发请求,处理效率是最高的。但也可以看到同样的业务逻辑不得不分散到3个回调函数中,编程复杂。

3.3 协程切换模型

这种模型也引入了异步技术。同时采用协程技术避免了callback。

非常简略的示意伪代码:

main_processing()
{
    loop
    {
        request = queue.get();
        create_coroutine (handle_request, request);
    }
}

handle_request(request)
{
    sendto(server1);
    reponse = switchout();
    
    sendto(server2);
    reponse = switchout();
    
    sendto(server3);
    reponse = switchout();
}

recieve_processing()
{
    loop
    {
        recievefrom( any );
        continue_coroutine();
    }
}

这里省略的很多实现的细节。但是通过协程技术,即保证了处理请求的并发性,同时也降低了编程的复杂度,基本和多线程模型的难度是一样的。实际上,在具体的语言和框架中,通过封装完全可以达到使用上与多线程模型完全一致,采用同一种"并发编程模型",虽然底层采用了完全不同的"并发模型"。在本文的第6节,会描述并发编程模型。

4. 多路间通信问题的解决模型

多路间通信问题的解决模型有2种:

  • 1.线程同步机制模型

    
    这种模型是基于共享内存和线程间的同步机制(即各种锁)
  • 2.message传递模型

    
    这种模型采用message传递的方式来进行多路间的通信。这种模型有两种具体的实现方式(也即有两种编程模型,在第6节会详细描述):actor模型,SCP模型。
    

5. 并发技术

上面提到了并发处理模型中使用的三种主要技术:
多核/多线程(进程)技术
异步技术
协程技术

这3种技术在鬓发处理模型中分别起到了不同的作用:
多核/多线程(进程)技术:提高同时处理的数量
异步技术:降低不必要的消耗
协程技术:降低编程难度

6. 并发编程模型

以上讨论的并发处理的模型,这些模型在不同的语言和框架中被设计成不同的形式,有不同的使用接口和方式。这些不同的使用方式就是并发编程模型。

6.1 多线程+线程同步机制模型

多线程+线程同步机制的编程模型是逻辑上最自然的的编程模型,也是最普通的编程模型,最容易理解。

在这种模型中,要并发处理任务就创建线程,线程间的通信采用共享内存和线程同步机制。如在第三节中的讨论,虽然使用方式和接口都一样,但是底层的实现可以采用完全不同的并发模型,所以这种编程模型中,的线程可以是物理线程,也可以是用户态线程。

在这种模型可以从这种多线程模型演变成线程池模型,但本质上是一样的。

在第7节,Java1.0和c++ threadstate库都属于这种编程模型。

6.2 线程池+任务+Future/Promise的编程模型

这种编程模型主要聚焦在线程同步机制。线程同步机制即各种锁,必须正确使用才能避免死锁等各种问题。这种编程模式就是提出了一种线程同步方式,也即总结一种比较常见的使用场景,提出了一个模式(pattern),简化线程同步。

一般这种的模式的接口定义如下:

Promise<T>
{
    Future Get_future();
    Set_value(T);
}

Future<T>
{
    T Get_value();
    Wait();
}

这种模式还有进一步的演化,就是Callback chain by then,添加新接口如下:

Future<T>
{
    Then(AsyncFunc)    ;
}

这里我们就不举例子,在第7节,我们会看到Java和scala是如何实现这种编程模型的。

6.3 基于事件+状态机的编程模型

我们在3.2节中看到,基于回调的并发模型,在编程模型层面,我们可以把callback抽象成事件,在复杂的业务逻辑中,很难控制callback间的跳转,这时我们可以引入有限状态机来进行管理。

6.4 Actor模型

在Actor模型中,actor是一个独立的单元,是一个用户态的线程,actor与actor之间采用message进行多路间的通信。message传递的机制是每个actor内部都有一个mailbox,actor可以向其他actor的mailbox投递消息,每个actor只能从自己的mailbox中取消息进行处理。

Actor模型的典型的实现是scala和erlang。其他语言也有对actor模型的实现,在第7节会详细描述各语言的实现。

6.5 CSP模型

CSP(Communicating Sequential Processes)也是一种基于message传递的并发编程模型。与actor模型的不同之处在于,在CSP中有2中角色,worker和channel。worker是用户态线程,channel用户message传递。

CSP模型是golang采用的编程模型。

7. 实际的例子

以上都是一些理论上的讨论,下一些比较典型的例子说明各种语言和框架对并发的支持。

7.1 Java

物理线程,线程同步机制

引入了ExecutorService, Callable, TaskFuture。

但是没有解决阻塞调用的问题

7.2 Node.js

7.3 Scala/Akka

基于java concurrent实现的logic线程,即actor,基于消息传递的mailbox

7.4 c++ StateThread 协程库

基于异步和协程技术,实现了多线程+线程同步机制模型。

这个种提供了线程管理、线程同步、网络访问等方法接口。

比如:

st_thread_create创建一个新的用户线程

st_read从网络中读取,这个操作会阻塞用户线程,但通过协程技术,物理线程切换到另外一个可运行的用户态线程继续执行

7.5 Go

协程,对阻塞系统调用的hack处理的green化方式,基于channel的消息传递模型

上一篇:RabbitMQ的transaction、confirm、ack三个概念的解释


下一篇:Zookeeper,etcd,consul内部机制和分布式锁和选主实现的比较