借助 C++ 进行 Windows 开发

追求高效的可组合异步系统

Kenny Kerr

 

Kenny Kerr计算机硬件的执行情况严重影响 C 编程语言的设计,以跟随到计算机编程的必要的方法。这种方法作为体现了程序状态的语句序列的描述一个程序。这是一种故意选择由 C 设计师丹尼斯 · 里奇。这让他产生的汇编语言可行的替代方案。里奇还通过结构化和程序的设计,已证明能有效地提高质量和可维护性的程序,导致极大地更加成熟、 强大的系统软件的创作。

特定的计算机程序集语言通常包含的处理器支持的指令集。程序员可以引用寄存器 — — 字面上的少量内存处理器本身上 — — 以及作为地址在主内存中。汇编语言也将包含一些用于跳转到不同的位置,在程序中,提供了一种简单的方法来创建可重用的例程的说明。在 C 中实现的功能,以保留少量内存称为"堆栈"。大多数情况下,此堆栈或调用堆栈,将存储有关每个函数,以便程序可以自动存储状态调用的信息 — — 本地和共享与它的调用方 — — 知道哪里执行应恢复功能完成后。这是这种基本的一部分今日 (星期三) 计算大多数程序员不给它的第二次思想,但它是什么使它可能编写高效、 易于理解的程序的令人难以置信的重要组成部分。请考虑以下代码:

int sum(int a, int b) { return a + b; }
int main()
{
  int x = sum(3, 4);
  return sum(x, 5);
}

给定的顺序执行假设,很明显 — — 如果不是明确 — — 该程序的状态将在任何给定的点。 第一个假定有没有一些自动存储的函数的参数和返回值,以及一些知道在哪里,当该函数调用返回时恢复执行该程序的方式,这些函数将毫无意义。 C 和 c + + 的程序员,它是在堆栈,使这成为可能,并允许我们写简单和有效的代码。 不幸的是,它也是我们依赖导致 C 和 c + + 程序员的伤害世界到异步的时候在堆栈上编程。 传统的系统编程语言 (如 C 和 c + + 必须调整以保持竞争力和生产力的世界充满了越来越异步操作。 虽然我怀疑 C 程序编制将继续依靠传统的技术来完成一段时间的并发性,但我希望 c + + 将会更快地发展和提供更丰富的语言,用来写入高效、 可组合的异步系统。

上个月探讨了一种简单的技术,您可以使用今天用任何 C 或 c + + 编译器来实现轻量级合作多任务处理通过模拟与宏无穷无尽。 虽然足够的 C 程序员,它挑战一些 c + + 程序员,自然和正确地依赖于其他打破抽象的构造之间的本地变量。 在本专栏中,我要寻找一个可能的未来方向,c + +,直接支持异步编程更自然和组合的方式。

任务和堆栈翻录

在我最后一列述 (msdn.microsoft.com/magazine/jj553509),并发性并不意味着多线程的编程。 这是合并两个单独的问题,而是流行足以引起一些混乱。 因为 c + + 语言最初并没有提供任何明确的支持并发性,程序员自然用不同的方法来实现相同。 随着程序也变得更加复杂,它变成了必要 — — 并可能很明显 — — 将程序分为逻辑的任务。 每个任务将是一种具有自己的堆栈的迷你程序。 通常情况下,操作系统实现此线程,并每个线程给自己的堆栈。 这将允许任务运行独立和经常抢先调度策略和多个处理核心的可用性。 但是,每个任务或迷你的 c + + 程序,是简单到编写和可以按顺序执行其堆栈隔离和体现了堆栈的状态。 这一任务的线程每种方法有一些明显的局限性,但是。 每个线程的开销是禁止在许多情况下的。 即使是不那么线程之间合作的缺乏导致多由于同步的必要性的复杂性对访问的共享状态,或线程之间进行通信。

很多流行的另一种方法是事件驱动编程。 也许是更明显,并发性并不意味着多线程编程,当你考虑的许多例子的事件驱动的编程,UI 发展和依靠的回调函数,实现任务合作管理的窗体库中。 但这种方法的局限性是至少有问题的一个线程每个任务的办法。 立即清洁、 顺序程序成为 web — — 或者,乐观,意粉堆栈 — — 回调函数而不是语句和函数调用的粘性序列。 这有时称为堆栈翻录,因为以前是一个函数调用的例程现在撕成两个或更多的功能。 这反过来也经常导致整个程序的涟漪效应。 翻录的堆栈是灾难性的如果你在所有关心的复杂性。 而不是一个函数,您现在有至少两个。 而不是依靠自动存储在堆栈上的本地变量,您必须现在显式管理这种状态的存储,就必须经得起之间一个堆栈位置及另一人。 简单的语言构造这样的循环必须重写,以适应这种分离。 最后,调试堆栈翻录程序是很难的因为该程序的状态,不再体现在堆栈中,往往必须手动"组装"程序员的头。 请考虑我的最后一列,表示同步操作,以提供显然顺序执行简单的闪存存储驱动程序的一种嵌入式系统的示例:

void storage_read(void * buffer, uint32 size, uint32 offset);
void storage_write(void * buffer, uint32 size, uint32 offset);
int main()
{
  uint8 buffer[1024];
  storage_read(buffer, sizeof(buffer), 0);
  storage_write(buffer, sizeof(buffer), 1024);
}

不难弄清楚怎么在这里。 堆栈的后盾的 1 KB 缓冲区传递给 storage_read 函数,暂停该程序,直到数据已被读取到缓冲区。 然后将此相同的缓冲区传递给 storage_write 函数,暂停该程序,直到在传输完成。 在这一点上,自动程序返回安全地回收的堆栈空间时所使用的复制操作。 该程序不做有用的工作,同时暂停,等待 I/O 完成的明显的缺点。

我的最后一列显示出一种简单的上一篇技术­多效率合作任务 c + + 中的一种方式,您可以返回到顺序的编程风格。 但是,如果不能使用本地变量,它有点有限。 虽然堆栈管理仍然是自动在函数调用和返回去,损失的自动堆栈变量是一个相当严重的限制。 尽管如此,它胜过全面爆发堆栈翻录。 考虑使用传统的事件驱动的方法前面的代码可能类似,您可以明显地看到翻录行动中的堆栈。 首先,存储函数将需要能重新声明,以容纳某种形式的事件通知,通常以一个回调函数:

typedef void (* storage_done)(void * context);
void storage_read(void * b, uint32 s, uint32 o, storage_done, void * context);
void storage_write(void * b, uint32 s, uint32 o, storage_done, void * context);

下一步,程序本身就需要重写实现适当的事件处理程序:

void write_done(void *)
{
  ...
signal completion ...
}
void read_done(void * b)
{
  storage_write(b, 1024, 1024, write_done, nullptr);
}
int main()
{
  uint8 buffer[1024];
  storage_read(buffer, sizeof(buffer), 0, read_done, buffer);
  ...
wait for completion signal ...
}

这是比早些时候的同步方法,显然更复杂,但它是很多今天在 C 和 c + + 程序规范。 请注意的复制操作,最初只限于的主要功能怎么现在传播以上三项职能。 不但如此,但你几乎需要到程序中反向,原因为 write_done 回调需要在 read_done 之前宣布,它需要在主函数之前宣布。 不过,此程序是有点过分简单化,和你应该明白如何这只会更麻烦,因为在现实世界中的任何应用程序中完全实现了"事件链"。

C + + 11 已取得一些显著的步骤,走向一个优雅的解决方案,但我们还没。 尽管 C + + 11 现在有很大的说并发在标准库中,它仍然是语言本身上基本上保持缄默。 库本身也别去远不足以使程序员能够方便地编写更复杂的组合和异步程序。 不过,已经完成伟大的工作,和 C + + 11 为进一步完善提供了良好的基础。 首先,我要告诉你什么 C + + 11 优惠,什么是缺少然后,最后,一个可能的解决方案。

关闭和 Lambda 表达式

一般来说,闭包是一个函数,加上一些识别功能需要执行的任何非局部信息的状态。 考虑我去年覆盖我的线程池系列中的 TrySubmitThreadpoolCallback 函数 (msdn.microsoft.com/magazine/hh335066):

void CALLBACK callback(PTP_CALLBACK_INSTANCE, void * state) { ...
}
int main()
{
  void * state = ...
TrySubmitThreadpoolCallback(callback, state, nullptr);
  ...
}

请注意该 Windows 函数如何接受一个函数以及一些国家。 这其实是一个封闭的伪装 ; 它肯定不像你典型的封闭,但功能是相同的。 可以说,函数对象实现同样的目的。 停止办公,一个一流的概念成名于功能的编程世界,但 C + + 11 方面取得进展,以支持这一概念,lambda 表达式的形式:

void submit(function<void()> f) { f(); }
int main()
{
  int state = 123;
  submit([state]() { printf("%d\n", state); });
}

在此示例中有一个简单提交函数,我们可以假装将导致提供的函数对象在一些其他上下文中执行。 函数对象创建从 lambda 表达式中的主要功能。 这个简单的 lambda 表达式包含必要的属性来限定为一个封闭和简约美以令人信服。 [状态] 部分表示什么状态是要进行"捕获",而其余部分实际上是对这种状态具有访问权限的匿名函数。 显然,你可以看到,编译器将创建一个函数对象要拔掉这高度的道德。 提交功能了一个模板,编译器可能甚至有优化掉函数对象本身,导致性能增益之外的句法的收益。 更重要的问题,但是,是这是否是真正的一个有效的封闭。 Lambda 表达式不会真正关闭所绑定的非局域变量的表达式吗? 本示例应澄清至少一部分的难题:

int main()
{
  int state = 123;
  auto f = [state]() { printf("%d\n", state); };
  state = 0;
  submit(f);
}

此程序将打印"123"并不是"0"因为状态变量被捕获的价值,而不是通过引用。 我可以,当然,告诉它来捕获通过引用的变量:

int main()
{
  int state = 123;
  auto f = [&]() { printf("%d\n", state); };
  state = 0;
  submit(f);
}

在这里我感到指定默认的捕获模式来捕获的引用,并让编译器计算出,我所指的状态变量的变量。 预计,该程序现在尽职尽责地打印"0"而不是"123"。这一问题,当然,是该变量的存储仍然绑定到在其中声明它的堆栈帧。 如果提交功能延迟执行堆栈回退,然后,国家将会丢失,并不正确,您的程序。

动态语言 (如 javascript) 来解决此问题,通过合并功能的样式,依赖于 C 必须世界远低于到堆栈上,与本质上是无序的联想容器的每个对象。 C + + 11 提供的这样和 make_shared 的模板,提供高效的替代品,即使他们不很简明。 因此,lambda 表达式和智能指针允许关闭,在上下文中定义,并允许从没有太多句法开销堆栈中释放出来的国家解决问题的一部分。 不是很理想,但这是一个开始。

承诺和期货

乍看起来,另一个 C + + 11 项称为期货的功能可能会出现提供答案。 你能想到的期货为赋的显式异步函数调用。 当然,面临的挑战是在界定什么完全意味着和其获取如何执行。 很容易解释期货为例。 未来启用版本的原始的同步 storage_read 函数可能像下面这样:

// void storage_read(void * b, uint32 s, uint32 o);
future<void> storage_read(void * b, uint32 s, uint32 o);

请注意,唯一的区别是返回类型裹在一个未来的模板。 想法是新的 storage_read 函数将开始或队列传输之前返回一个未来的对象。 这一未来可以再用作同步对象等待操作完成:

int main()
{
  uint8 buffer[1024];
  auto f = storage_read(buffer, sizeof(buffer), 0);
  ...
f.wait();
  ...
}

这可能会被称为异步方程的消费者结束。 Storage_read 函数文摘走了提供程序的结尾,并同样简单。 Storage_read 函数将需要创建一个承诺和队列中的请求参数和返回的相关联的未来。 同样,这是容易理解的代码中:

future<void> storage_read(void * b, uint32 s, uint32 o)
{
  promise<void> p;
  auto f = p.get_future();
  begin_transfer(move(p), b, s, o);
  return f;
}

一旦操作完成后,存储驱动程序可以发送信号到未来它是准备好了:

p.set_value();

这是什么价值? 好吧,没有价值,因为我们要使用的前途和未来的专门化 void,但是你可以想象之上这可能包括一个 file_read 函数的存储驱动程序的文件系统抽象。 此函数可能需要被称为无需知道某个特定文件的大小。 然后,它可以返回的实际传输的字节数:

future<int> file_read(void * b, uint32 s, uint32 o);

在这种情况下,也会用一个具有 int 类型的承诺,从而提供渠道,进行通信的字节数实际传输:

promise<int> p;
auto f = p.get_future();
...
p.set_value(123);
...
f.wait();
printf("bytes %d\n", f.get());

未来提供 get 方法通过其可能获得的结果。 很好,我们有一种等待未来,和我们所有的问题都解决了! 嗯,不是那么快。 这不会真正解决我们的问题呢? 我们同时可以启动多个操作吗? 可以。 我们可以轻松地撰写的聚合运算或甚至只是等待任何或所有未完成的操作呢? 号 在原始的同步示例中,读取的操作一定完成写操作之前就开始了。 所以期货做不其实离我们这么远。 问题是等待一个未来的行为仍然是同步操作,有没有标准的方式来撰写一连串的事件。 也是没有办法来创建期货的聚合。 您可能想要等待不一,但任何数量的期货。 您可能需要等待所有期货或只是第一就是准备好了。

在将来的期货

期货与承诺的问题是他们还远远不够,可以说完全的缺陷。 如方法等待获取,两者的阻塞,直到结果就是准备好了,对联并发和异步编程。 我们需要将尝试检索的结果,如果的 try_get 之类的练习,这是可用的但返回立即,无论:

int bytes;
if (f.try_get(bytes))
{
  printf("bytes %d\n", bytes);
}

进一步说,期货应提供一个延续机制以便我们可以简单地 lambda 表达式与相关联的异步操作完成。 这是当我们开始看到的期货可组合性:

int main()
{
  uint8 buffer[1024];
  auto fr = storage_read(buffer, sizeof(buffer), 0);
  auto fw = fr.then([&]()
  {
    return storage_write(buffer, sizeof(buffer), 1024);
  });
  ...
}

Storage_read 函数返回读取未来 (fr),lambda 表达式用来建造这未来使用其当时的方法,从而导致写未来 (fw) 的延续。 因为总是返回期货,您可能更愿意更隐式但等效的样式:

auto f = storage_read(buffer, sizeof(buffer), 0).then([&]()
{
  return storage_write(buffer, sizeof(buffer), 1024);
});

在这种情况下还有只有单个明确未来代表的所有操作的高潮。 这可能会调用顺序组成,但并行 AND 和 OR 是组成也会最平凡的系统 (认为 WaitForMultipleObjects) 的必要条件。 在这种情况下,我们将需要一对 wait_any 和 wait_all variadic 函数。 同样的这些将返回期货,使我们能够提供 lambda 表达式作为继续像以前那样使用然后方法的总和。 它也可能非常有用,可以将已完成的未来传递给在那里完成的特定未来不明显的情况下继续进行。

更详尽看未来的期货,其中包括基本主题的取消,请看看阿图尔 · Laksberg 和尼古拉斯尔斯 · 古斯塔夫松纸张,"A 标准编程接口为异步操作,"在 bit.ly/MEgzhn

敬请关注的下一期,我哪里的期货未来更深入地挖掘,向您展示更多流体化的写作高效、 可组合的异步系统。

肯尼 Kerr 是充满热情的本机 Windows 开发的软件工匠。 您可以通过 kennykerr.ca 与他联系。

衷心感谢以下技术专家对本文的审阅:Artur Laksberg