14.1.3 基于任务的并行度(task-based parallelism)

728 篇文章 1 订阅
26 篇文章 0 订阅
 

14.1.3 基于任务的并行度(task-based parallelism)

 

    第 11 章中,我们看到,在函数式程序中,可以轻松地跟踪函数调用之间的依赖关系。一个函数或代码块可以做的唯一的事情,就是取值作为参数,并产生一个结果。如果我们想要找出一个调用是否依赖于于其他调用,可以检查它是否把第一个调用的输出作为自己输入的一部分。这可能要感谢使用了不可变的数据结构。如果第一个调用可以修改共享状态,那么,第二个调用会依赖这种改变,我们就不可能改变这些调用的顺序,尽管它没有明确地出现在调用代码中。,我们看到的代码块之间的依赖关系,这一事实对于基于任务的并行度,是至关重要的。我们已经讨论过基于数据的并行度(data-based parallelism),它以并行方式,对不同的输入执行相同的任务;基于任务的并行度,并发地执行 (可能是不同的)任务。

    清单 14.6 显示一个 F# 脚本,以递归方式处理不可变数据结构。这里,我们将看一个简单示例,但是,在 14.3.5 节,展示了更复杂的情况。该代码使用我们在第 10 章设计的二叉树类型,实现一个函数来计算在树中的素数的个数。对于基于任务的并行性,这并不是最典型的示例,因为我们将创建两个类似的任务,但它很好地介绍了我们可以使用的技术。

 

Listing 14.6 Counting primes in a binary tree (F# Interactive)

 

> type IntTree =
     | Leaf of int
     | Node of IntTree * IntTree;;
type IntTree = (...)

> let rnd = new Random()
    let rec tree(depth) =
      if depth = 0 then Leaf(rnd.Next())
      else Node(tree(depth - 1), tree(depth - 1));;
val rnd : Random
val tree : int –> IntTree

> let rec count(tree) =
      match tree with
     | Leaf(n) when isPrime(n) –> 1
     | Leaf(_) –> 0
     | Node(left, right) -> count(left) + count(right);;
val count : IntTree –> int

 

    清单 14.6 首先声明一个二叉树的数据结构,它可以存储 int 类型的值。然后,我们实现一个函数,生成包含随机数的树。这个函数是递归的,取需要的树的深度,作为参数值。当我们生成一个节点,由两个子树组成,这个函数递归地生成子树,深度递减 1。

    我们实现 count 函数,它使用模式匹配,来处理三种情况:

    ■ 叶节点是素数,则返回 1。

    ■ 叶节点是非素数,则返回 0。

    ■ 有两个子树的节点,递归地计数这些子树中的素数。

    注意,计数在左子树和右子树中的素数的任务是相互独立的。在下一节中,我们将看到如何并行运行这些调用。

 

在 F# 中基于任务的并行度

 

    在上一节中,我们使用 Parallel Extensions to .NET 的 PLINQ 组件。若要实现基于任务的并行度,我们要使用来自 TPL 的类。这是一个较低级别的库,可以创建将由 .NET 运行时并行执行的任务。在本节中,我们将使用一个泛型类 Task<T>。

    你会看到,在 F# 中处理这种类是很容易的。当创建任务时,我们使用 TaskFactory<T> 类,它有一个方法,叫 StartNew,获取 factory(工厂类) 的实例,我们可以使用 Task.Factory 的静态属性。现在,让我们来看如何可以使用任务,去并行化来自 14.6 的 count 函数 。清单 14.7 中最有趣的部分,当一个树是有两个子树节点的情况,可以用递归方式处理。

 

Listing 14.7 Parallel processing of a binary tree (F#)

 

let pcount(tree) =
  let rec pcountDepth(tree, depth) =
    match tree with
    | _ when depth >= 5 -> count(tree)
    | Leaf(n) when isPrime(n) –> 1
    | Leaf(_) –> 0
    | Node(left, right) –>
      let countLeft = Task.Factory.StartNew(fun() –>
        pcountDepth(left, depth + 1))
      let countRight = pcountDepth(right, depth + 1)
      countLeft.Result + countRight
  pcountDepth(tree, 0)

 

    在递归过程中,我们需要保存额外的参数值,所以,创建了一个局部函数,pcountDepth,额外的参数值 (名为 depth)指定当前正在处理的树的深度。这使得我们在创建了大量的以并行方式运行任务之后,可以使用这个函数(count)的非并行版本。如果我们为每个树节点创建单独的任务,创建新任务的开销将超出以并行方式运行计算得到的好处。在一个双核的计算机上创建数千的任务并不是一个好主意。这个开销并不像为每个任务创建额外的线程,一样糟糕,但仍是非零。

    参数值 depth 在每次递归调用时增加。一旦它超过阈值,我们使用顺序算法处理树的其余部分。在清单 14.7 中,我们用模式匹配对此进行测试,阈值设置为 5(这意味着,我们能创建大约 31 个任务)。

    当我们处理树的非叶节点时,创建 Task<int> 类型的值,给它一个处理左子树的函数。Task 类型表示计算,当它创建时,会启动并行执行,当我们在将来某个时候需要它时,会给出结果。这是值得注意的,我们不要为其他子树创建任务。如果我们那样做,调用者线程会必须等待收集两个结果,并不会做任何有用的工作。我们没有立即开始以递归方式处理第二个子树。一旦我们完成了递归调用,就需要计算两子树值的和。要获取由这个任务计算的值,可以使用 Result 属性。如果任务还没有完成,调用将会阻塞,直到值可用时。执行模式可能是难以理解,但是,图 14.1 在图形化的方式显示的结果。

image

图 14.1 在根节点中,我们创建 Task 1 来处理左子树,立即开始处理右子树。重复处理两个子树,并创建两个更多的任务。

 

    就像在 14.1.2 节中的数据并行化的示例一样,我们感兴趣的是,可以从任务并行化中得到的性能收益。我们还是在 F# Interactive 中,用 #time 指令很方便地测量加速:

 

> let t = tree(16);;
> count(t);;
Real: 00:00:00.591, CPU: 00:00:00.561

> pcount(t);;
Real: 00:00:00.386, CPU: 00:00:00.592

 

    你可以看到,统计数据看起来不错。像我们前面的示例一样,加速 在 180 到 185%之间。我们取得这样好成绩的原因之一是,树是平衡的;所有的叶节点有相同的深度。如果我们预先不知道是否是这种情况,生成更多的任务,还是明智的,可以确保工作会在处理器之间均匀分布。在我们的示例中,可以通过增加阈值来实现。

    到目前为止,我们只展示了 F# 中基于任务的并行度的代码,因为在 F# 中,实现二叉树很容易,当然,在 C# 中,也是可行的。在这里,我们没有显示所有的代码,只会看一些 C# 版本的关键部分。本书的网站上提供了完整的代码。

 

C# 中基于任务的并行度

 

    在 C# 中,我们首先需要实现类,用于表示二叉树。我们实现一个 IntTree 类,它有两个方法,允许我们测试树是叶子,还是节点:

 

bool TryLeaf(out int value);
bool TryNode(out IntTree left, out IntTree right);

 

    如果树分别是叶子或节点,两个方法返回则为 true。在这种情况下,方法还使用 out 参数,返回叶子或节点的细节。清单 14.8 展示如何在 C# 中实现顺序和并行版本的树处理的代码。

 

Listing 14.8 Sequential and parallel tree processing using tasks (C#)

 

static int Count(IntTree tree) {
  int value;
  IntTree left, right;

  if (tree.TryLeaf(out value)) return IsPrime(value) ? 1 : 0;
  else if (tree.TryNode(out left, out right)
    return Count(left) + Count(right);
  throw new Exception();
}

static int CountParallel(IntTree tree, int depth) {
  int value;
  IntTree left, right;

  if (depth >=5) return Count(tree); 
  if (tree.TryLeaf(out value)) return IsPrime(value) ? 1 : 0;
  else if (tree.TryNode(out left, out right)) {
    var countLeft = Task.Factory.StartNew(() =>
      CountParallel(left, depth + 1));
    var countRight = CountParallel(right, depth + 1);
    return countLeft.Result + countRight;
  }
  throw new Exception();
}

 

    对于一个节点,顺序版本以递归方式处理左和右子树。当处理叶子时,它测试数字是否是素数,返回 1 或 0。树总是节点或叶子,所以,这个方法的最后一行应永远无法达到。

    在并行版本中,我们有一个额外的参数值,表示深度。当深度超过阈值,我们使用顺序 Count 方法计算结果。

    当以并行方式处理节点时,我们创建一个任务,处理左子树,并立即处理右子树。程序等待这两个操作完成,并把结果加起来。

    这是几乎直译 F# 代码。来自 System.Threading.Tasks 命名空间 Task<T> 的类型,可以用类似的方式,在 F# 和 C# 中使用。唯一重要的事情是,由这个任务执行的计算不应该有(或依赖于)任何副作用。Task<T> 类型与 Lazy<T>类型是惊人的相似,我们在第 11 章中实现的。

 

任务和延迟值

 

    当我们在第 11 章中讨论延迟值时,曾经着重强调过这一事实,我们可以使用它们,而不需要指定什么时应该执行这个值。这这种情况也适用于任务。函数只计算一次。当延迟值第一次需要时,才计算结果,而未来的值在工作线程变为可用时,才完成这个计算。

    看待 Task<T> 和 Lazy<T> 之间相似性的另一种方法,是看我们可以用它们所做的操作。构建一个任务或延迟值时,我们从计算这个值的函数中创建。这样的 F# 类型签名是 (unit -> 'a) -> T<'a>,其中 T 可以是 Lazy,也可以是 Task。第二个操作是访问这个值。它只是取一个延迟或未来值,并给我们这个计算的结果,所以,这个类型签名是 T<'a> -> 'a。

 

    在本节中,我们看了在这一章正在讨论的三个并行化函数编程技术中的最后一个。当我们以递归方式处理大型的不可变数据结构时,基于任务的并行度是特别有用的。这种计算在函数式编程中是常见的,所以,基于任务的并行度是我们的工具集一大补充,连同进行声明性数据处理。

    现在,我们更深入地回到我们的第一个主题:对命令式代码并行化,对外界隐藏,以保持程序的函数性。我们将使用一个大型的应用程序来演示,对图像应用图形滤镜。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值