F#在Mono下的任务并行似乎并未真正实现并行执行

5

我有以下虚拟代码来测试F#中的TPL。(Mono 4.5,Xamarin Studio,四核MacBook Pro)

让我惊讶的是,所有进程都在同一线程上完成。根本没有并行性。

open System
open System.Threading
open System.Threading.Tasks


let doWork (num:int) (taskId:int) : unit =
    for i in 1 .. num do
        Thread.Sleep(10)
        for j in 1 .. 1000 do
            ()
        Console.WriteLine(String.Format("Task {0} loop: {1}, thread id {2}", taskId, i, Thread.CurrentThread.ManagedThreadId)) 

[<EntryPoint>]
let main argv = 

    let t2 = Task.Factory.StartNew(fun() -> doWork 10 2)
    //printfn "launched t2"
    Console.WriteLine("launched t2")
    let t1 = Task.Factory.StartNew(fun() -> doWork 8 1)
    Console.WriteLine("launched t1")
    let t3 = Task.Factory.StartNew(fun() -> doWork 10 3)
    Console.WriteLine("launched t3")
    let t4 = Task.Factory.StartNew(fun() -> doWork 5 4)
    Console.WriteLine("launched t4")
    Task.WaitAll(t1,t2,t3,t4)
    0 // return an integer exit code

然而,如果我将线程休眠时间从10ms增加到100ms,我可以看到一点并行性。
我做错了什么?这是什么意思?我确实考虑过CPU在TPL启动新线程的任务之前完成工作的可能性。但是对我来说这没有意义。我可以将内部虚拟循环`for j in 1 .. 1000 do ()`的循环次数增加1000次。结果是相同的:没有并行性(`thread.sleep`设置为10ms)。
另一方面,在C#中相同的代码则产生了期望的结果:所有任务以混合顺序(而不是顺序执行)将消息打印到窗口。
更新:
如建议所示,我将内部循环更改为执行一些“实际”的操作,但结果仍然是在单个线程上执行。
更新2:
我不太理解Luaan的评论,但我刚刚在朋友的电脑上进行了测试。使用相同的代码,多线程并行运行(无需线程休眠)。看起来是与Mono有关。但是Luaan能再次解释一下我应该从TPL期望什么吗?如果我有要并行执行并利用多核CPU的任务,那么TPL难道不是正确的选择吗?
更新3:
我再次尝试了@FyodorSoikin的建议,使用不会被优化掉的虚拟代码。不幸的是,工作量仍然无法使Mono TPL使用多个线程。目前我唯一能让Mono TPL分配多个线程的方法是强制现有线程休眠超过20ms。我没有足够的资格断言Mono是错误的,但我可以确认相同的代码(相同的基准工作量)在Mono和Windows下具有不同的行为。

我认为你应该使用Thread而不是Task。任务的本质更多是异步而不是并行的。你不能确定它们将在许多线程中执行。 参见:https://dev59.com/jmYr5IYBdhLWcg3wvspA - pizycki
我猜自由内部循环会被优化掉,因为它什么也没做。尝试在其中放置一些不可优化的内容(比如库函数调用),看看是否有所不同。 - Fyodor Soikin
@FyodorSoikin 没有太大关系。 即使它没有被优化掉,循环一千次也几乎无法测量。 只有在使用 Thread.SpinWait(1000000) 时才开始看到一些执行时间,大约为5毫秒 - 在现代CPU上,1000太低了。 - Luaan
陪审团已经做出了决定:代码确实被优化掉了,我用ILSpy看过了。@Luaan,1000个空循环确实是无法察觉的,但如果你放置一些非平凡的东西(见我的回答),那就没问题了。 - Fyodor Soikin
回复 @FyodorSoikin,让内部循环执行一些“实际”的操作没有任何区别。我只是将内部循环替换为阶乘2000,但仍然是同一个线程。我能想到的唯一方法是让它们在不同的线程上执行是将执行线程休眠。 - casbby
你似乎没有理解重点。编译器完全能够看到你的 fact 函数没有副作用,并且你忽略了它的返回值。因此,优化掉它是完全可以的。你需要做一些编译器不知道如何优化的事情。请参考我的答案。 - Fyodor Soikin
2个回答

6
看起来 Sleep 被完全忽略了 - 看看 Task 2 loop 是如何在启动下一个任务之前被打印出来的,这太愚蠢了 - 如果线程等待了10毫秒,这是不可能发生的。
我认为原因可能是操作系统中的计时器分辨率。 Sleep 远非准确 - 很可能 Mono(或 Mac OS)决定,既然它们无法可靠地让您在10毫秒内再次运行,那么最好的选择就是让您立即运行。这在 Windows 上是不起作用的 - 在那里,只要您不执行 Sleep(0),就保证失去控制;您总是至少睡眠与您想要的一样长的时间。似乎在 Mono / Mac OS 上,思路是相反的 - 操作系统试图让您睡眠的时间最多等于您指定的时间。如果您想睡眠的时间少于计时器精度,那就太糟糕了 - 没有睡眠。
但即使它们没有被忽略,线程池也没有太多压力来给您更多的线程。您只阻塞了不到100毫秒,对于四个任务而言,这还不足以让线程池开始创建新的线程来处理请求(在 MS.NET 上,只有在200毫秒没有任何空闲线程时才会生成新线程,如果我没记错的话)。您所做的工作根本不足以值得启动新线程!
你可能忽略了一个重点,即 Task.Factory.StartNew 实际上从未启动任何新线程。相反,它在默认任务调度程序上安排相关任务 - 这只是将其放入线程池队列中,作为要在“最早方便”的情况下执行的任务。如果池中有一个空闲线程,则第一个任务几乎立即在那里开始运行。第二个任务将在有另一个空闲线程时运行,依此类推。只有当线程使用情况“糟糕”(即线程“阻塞” - 它们不执行任何 CPU 工作,但它们也不是空闲的)时,线程池才会生成新线程。

TPL 的整个重点在于 4 个任务应该被启动并且并行执行。我期望的是任务启动的消息混合在其他消息中。整个重点不是让所有事情按顺序执行。 - casbby
1
@casbby 不,这不是TPL的目的。TPL的目的是使安全地处理并行和异步任务变得容易。您仍然要依靠底层的.NET框架和操作系统来决定最佳方式。这根本不需要包括任何并行性。如果您将测试内容设置为实际有用且需要更多时间的内容,您很快就会发现您期望的并行级别可以轻松实现而没有任何问题。创建线程相对昂贵 - 在时间和内存方面都是如此。除非您需要它们,否则应避免创建它们。 - Luaan
看起来你指出了Mono与Windows不同的地方。我知道TPL依赖于线程池来调度任务。这就是为什么在机器实际上处于空闲状态时,其他任务没有分配线程让我更加惊讶的原因。对于并行任务,TPL是最好的选择,不是吗?我希望程序(一个批处理程序处理大量矩阵计算)尽可能多地占用CPU。我可以接受机器在那段时间内反应稍微慢一些,并启动风扇。请给予建议? - casbby
1
@casbby 没错,坚持使用TPL吧。出问题的不是TPL,而是你的基准测试 :) 它对于任何真实工作负载都能很好地运行 - 它会倾向于将负载均匀地分散到所有可用的核心上(当然,只要你能避免在任务之间共享数据)。除非TPL或线程池在Mono中出了问题,但如果它像这样出了问题,那它就基本没用了,所以我不会押注在这上面。 - Luaan
关于Mac OS上的Mono和Windows上的.NET之间的区别,似乎它们都忽略了Sleep(在某些时间间隔下),并且很可能以不同的方式初始化线程池。但是它们不应影响典型并行程序的行为-只会影响你的基准测试结果。 - Luaan

5
如果你查看这个程序的IL输出,你会发现内部循环已被优化掉,因为它没有任何副作用,且其返回值完全被忽略。
如果要计数,需要放一些无法被优化的内容,并使其更重:与启动新任务的成本相比,1000个空循环几乎不可感知。
例如:
let doWork (num:int) (taskId:int) : unit =
    for i in 1 .. num do
        Thread.Sleep(10)
        for j in 1 .. 1000 do
            Debug.WriteLine("x")
        Console.WriteLine(String.Format("Task {0} loop: {1}, thread id {2}", taskId, i, Thread.CurrentThread.ManagedThreadId)) 

更新:
添加一个纯函数,比如你的fact,是没有用的。编译器可以很好地看到fact没有副作用,并且你忽略了它的返回值,因此,它完全可以被优化掉。你需要做一些编译器不知道如何优化的事情,比如上面的Debug.WriteLine


感谢您的建议并给了我使用ilspy的想法。我按照建议的代码在mono上重新进行了测试。我仍然无法欺骗mono使用多个线程。我唯一得到多线程的方法是在现有线程上强制进行20毫秒或更长时间的休眠。但在Windows上,即使没有真正的内部循环处理,也会为任务分配多个线程。 - casbby

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接