C++ 并发编程的一种思维

时间:2022-11-10 18:02:24

愈发紧迫的挑战

现今,单台机器拥有多个独立的计算单元已经太常见了,这点在服务器的处理器上表现尤为明显,据 AMD 的一张 2012-2013 服务器路线图显示,服务器处理器的核心数将在 2013 年达到 20 颗之多。合理的利用 CPU 资源已是一个不得不考虑的问题。不少 C++ 程序员依然使用着多线程模型,但是对多线程的掌控并不是一件容易的事情,开发中容易出错、难以调试。有些开发者为了避免多线程带来的复杂度而弃用多线程,有些开发者则另投其他语言阵营,例如 Erlang。其实我们还有其他的选择,Theron 就是其中之一。

什么是 Theron?

Theron 是一个用于并发编程的 C++ 库(http://www.theron-library.com/),通过 Theron 我们可以避免多线程开发中各种痛处,例如:共享内存、线程同步。Theron 通过 Actor 模型向我们展示了另一种思维。

什么是 Actor 模型?

Erlang 因为其优秀的并发特性而被大家所关注,而其并发特性的关键之一就是在于其采用了 Actor 模型(http://c2.com/cgi/wiki?ErlangLanguage)。与 Actor 模型相对应的模型则是我们在面向对象编程中使用的 Object 模型,Object 模型中宣扬,一切皆为 Object(对象),而 Actor 模型则认为一切皆为 Actor。Actor 模型中,Actor 之间通过消息相互通信,这是其和 Object 模型的一个显著的区别,换而言之 Actor 模型使用消息传递机制来代替了 Object 模型中的成员方法调用。这样做意义重大,因为相对于成员方法的调用来说,消息的发送是非阻塞的,它无需等待被调用方法执行完成就可以返回,下图显示了此种区别:

C++ 并发编程的一种思维

A::a() 调用了 objB.b(),此时 A::a() 必须等待 B::b() 的返回才能继续执行。在 Actor 模型中,对应的做法是 Actor A 向 Actor B 发送消息并立即返回,这时候 Actor A 可以继续执行下去,与此同时 Actor B 收到消息被唤醒并和 Actor A 并行执行下去。

Theron 中的每个 Actor 都会绑定一个唯一的地址,通过 Actor 的地址就可以向其发送消息了,每个 Actor 都有一个消息队列。从编码者的角度看来,每实例化一个 Actor 都创建了一个和 Actor 相关的“线程”(非系统级的线程)。每个 Actor 总是被单线程的执行。总结来说 Theron 的并发特性的关键就在于:每个 Actor 在属于自己的单个“线程”中执行,而多个 Actor 并发执行。

Hello Theron

在谈及更多内容之前,我们先来看看 Theron 的一个简单的范例,借以获得一个最直观的印象。在 http://www.theron-library.com/ 可以下载到 Theron 的最新版,Theron 提供了 makefile 便于 gcc 用户编译,同时其也为 Windows 用户提供了 Visual Studio solution 文件 Theron.sln 用于构建 Theron。编译 Theron 很容易,不会有太多的障碍,需要注意的是构建 Theron 需要指定依赖的线程库,Theron 支持三种线程库:std::thread(C++11 标准线程库)、Boost.Thread 和 Windows threads。使用 makefile 构建时,通过 threads 参数指定使用的线程库(更为详细的信息参考:http://www.theron-library.com/index.php?t=page&p=gcc),使用 Visual Studio 构建时,通过选择适当的 Solution configuration 来指定使用的线程库(更为详细的信息参考:http://www.theron-library.com/index.php?t=page&p=visual studio)。下面我们来看一个最简单的范例:

#include <stdio.h>

#include <Theron/Framework.h>
#include <Theron/Actor.h>

// 定义一个消息类型
// 在 Theron 中,任何类型都可以作为一个消息类型
// 唯一的一个约束是消息类型的变量能够被拷贝的
// 消息按值发送(而非发送它们的地址)
struct StringMessage
{
char m_string[64];
};

// 用户定义的 Actor 总需要继承于 Theron::Actor
// 每个 Actor 和应用程序的其他部分通信的唯一途径就是通过消息
class Actor : public Theron::Actor
{
public:
inline Actor()
{
// 注册消息的处理函数
RegisterHandler(this, &Actor::Handler);
}

private:
// 消息处理函数的第一个参数指定了处理的消息的类型
inline void Handler(const StringMessage& message, const Theron::Address from)
{
printf("%s\n", message.m_string);
if (!Send(message, from))
printf("Failed to send message to address %d\n", from.AsInteger());
}
};


int main()
{
// Framework 对象用于管理 Actors
Theron::Framework framework;

// 通过 Framework 构建一个 Actor 实例并持有其引用
// Actor 的引用类似于 Java、C# 等语言中的引用的概念
// Theron::ActorRef 采用引用计数的方式实现,类似于 boost::shared_ptr
Theron::ActorRef simpleActor(framework.CreateActor<Actor>());

// 创建一个 Receiver 用于接收 Actor 发送的消息
// 用于在非 Actor 代码中(例如 main 函数中)与 Actor 通信
Theron::Receiver receiver;

// 构建消息
StringMessage message;
strcpy(message.m_string, "Hello Theron!");

// 通过 Actor 的地址,我们就可以向 Actor 发送消息了
if (!framework.Send(message, receiver.GetAddress(), simpleActor.GetAddress()))
printf("Failed to send message!\n");

// 等到 Actor 发送消息,避免被关闭主线程
receiver.Wait();

return 0;
}

这个范例比较简单,通过 Actor 输出了 Hello Theron。需要额外说明的一点是消息在 Actor 之间发送时会被拷贝,接收到消息的 Actor 只是引用到被发送消息的一份拷贝,这么做的目的在于避免引入共享内存、同步等问题。

Theron 的消息处理

前面谈到过,每个 Actor 都工作在一个属于自己的“线程”上,我们通过一个例子来认识这一点,我们修改上面例子中的 Actor:: Handler 成员方法:

inline void Handler(const StringMessage& message, const Theron::Address from) 
{
while (true)
{
printf("%s --- %d\n", message.m_string, GetAddress().AsInteger());
#ifdef _MSC_VER
Sleep(1000);
#else
sleep(1);
#endif
}
}

此 Handler 会不断的打印 message 并且带上当前 Actor 的地址信息。在 main 函数中,我们构建两个 Actor 实例并通过消息唤醒它们,再观察输出结果:

Hello Theron! --- 1
Hello Theron! --- 2
Hello Theron! --- 2
Hello Theron! --- 1
Hello Theron! --- 2
Hello Theron! --- 1
Hello Theron! --- 2
Hello Theron! --- 1
......

这和我们预期的一样,两个 Actor 实例在不同的线程下工作。实际上,Framework 创建的时候会创建系统级的线程,默认情况下会创建两个(可以通过 Theron::Framework 构造函数的参数决定创建线程的数量),它们构成一个线程池,我们可以根据实际的 CPU 核心数来决定创建线程的数量,以确保 CPU 被充分利用。线程池的线程是以何种方式进行调度的?如下图:

C++ 并发编程的一种思维

接收到消息的 Actor 会被放置于一个线程安全的 Work 队列中,此队列中的 Actor 会被唤醒的工作线程取出,并进行消息的处理。这个过程中有两个需要注意的地方:

  1. 对于某个 Actor 我们可以为某个消息类型注册多个消息处理函数,那么此消息类型对应的多个消息处理函数会按照注册的顺序被串行执行下去
  2. 线程按顺序处理 Actor 收到的消息,一个消息未处理完成不会处理消息队列中的下一个消息 我们可以想象,如果存在三个 Actor,其中两个 Actor 的消息处理函数中存在死循环(例如上例中的 while(true)),那么它们一旦执行就会霸占两条线程,若线程池中没有多余线程,那么另一个 Actor 将被“饿死”(永远得不到执行)。我们可以在设计上避免这种 Actor 的出现,当然也可以适当的调整线程池的大小来解决此问题。Theron 中,线程池中线程的数量是可以动态控制的,线程利用率也可以测量。但是务必注意的是,过多的线程必然导致过大的线程上下文切换开销。

一个详细的例子

我们再来看一个详细的例子,借此了解 Theron 带来的便利。生产者消费者的问题是一个经典的线程同步问题,我们来看看 Theron 如何解决这个问题:

#include <stdio.h>

#include <Theron/Framework.h>
#include <Theron/Actor.h>

const int PRODUCE_NUM = 5;

class Producer : public Theron::Actor
{
public:
inline Producer(): m_item(0)
{
RegisterHandler(this, &Producer::Produce);
}

private:
// 生产者生产物品
inline void Produce(const int& /* message */, const Theron::Address from)
{
int count(PRODUCE_NUM);

while (count--)
{
// 模拟一个生产的时间
#ifdef _MSC_VER
Sleep(1000);
#else
sleep(1);
#endif

printf("Produce item %d\n", m_item);
if (!Send(m_item, from))
printf("Failed to send message!\n");
++m_item;
}
}

// 当前生产的物品编号
int m_item;
};

class Consumer : public Theron::Actor
{
public:
inline Consumer(): m_consumeNum(PRODUCE_NUM)
{
RegisterHandler(this, &Consumer::Consume);
}

private:
inline void Consume(const int& item, const Theron::Address from)
{
// 模拟一个消费的时间
#ifdef _MSC_VER
Sleep(2000);
#else
sleep(2);
#endif

printf("Consume item %d\n", item);
--m_consumeNum;

// 没有物品可以消费请求生产者进行生产
if (m_consumeNum == 0)
{
if (!Send(0, from))
printf("Failed to send message!\n");
m_consumeNum = PRODUCE_NUM;
}
}

int m_consumeNum;
};

int main()
{
Theron::Framework framework;

Theron::ActorRef producer(framework.CreateActor<Producer>());
Theron::ActorRef consumer(framework.CreateActor<Consumer>());

if (!framework.Send(0, consumer.GetAddress(), producer.GetAddress()))
printf("Failed to send message!\n");

// 这里使用 Sleep 来避免主线程结束
// 这样做只是为了简单而并不特别合理
// 在实际的编写中,我们应该使用 Receiver
#ifdef _MSC_VER
Sleep(100000);
#else
sleep(100);
#endif

return 0;
}

生产者生产物品,消费者消费物品,它们并行进行,我们没有编写创建线程的代码,没有构建共享内存,也没有处理线程的同步。这一切都很轻松的完成了。

代价和设计

和传统的多线程程序相比 Theron 有不少优势,通过使用 Actor,程序能够自动的并行执行,而无需开发者费心。Actor 总是利用消息进行通信,消息必须拷贝,这也意味着我们必须注意到,在利用 Actor 进行并行运算的同时需避免大量消息拷贝带来的额外开销。

Actor 模型强调了一切皆为 Actor,这自然可以作为我们使用 Theron 的一个准则。但过多的 Actor 存在必然导致 Actor 间频繁的通信。适当的使用 Actor 并且结合 Object 模型也许会是一个不错的选择,例如,我们可以对系统进行适当划分,得到一些功能相对独立的模块,每个模块为一个 Actor,模块内部依然使用 Object 模型,模块间通过 Actor 的消息机制进行通信。

Theron 的未来

Theron 是个有趣的东西,也许你没有使用过它,你也不了解 Actor 模型,但是 Actor 的思想却不新鲜,甚至你可能正在使用。目前来说,我还没有找到 Theron 在哪个实际的商业项目中使用,因此对 Theron 的使用还存在一些未知的因素。还有一些特性,诸如跨主机的分布式的并行执行是 Theron 不支持的,这些都限制了 Theron 的使用,不过作者正在积极的改变一些东西(例如,作者表示会在今后添加 Remote Actors)。无论 Theron 未来如何,Theron 以及 Actor 模型带来的思想会让我们更加从容面对多核的挑战。

作者简介

梁国栋,热爱编程,热衷于撰写技术类文章,精益思想倡导者,UNIX 哲学实践者,专注于高性能服务器程序的研发多年,目前负责网游服务器的研发工作。


感谢李永伦对本文的审校。

给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ)或者腾讯微博(@InfoQ)关注我们,并与我们的编辑和其他读者朋友交流。