好的多线程设计是过早优化吗?

10

虽然我喜欢来自多核系统设计的智力挑战,但我认识到其中大部分都只是不必要的过早优化。

然而通常所有系统都有一些性能需求,稍后重构为多线程安全操作困难或甚至在经济上也不可行,因为这将需要完全重写使用另一个算法。

你是如何在优化和完成任务之间保持平衡的呢?


这是相当主观的,建议使用 CW。 - Rex M
你能提供更多具体的例子吗?我认为“一般性陈述”是错误的。我的意思是,显然,一个服务端组件需要提供良好的“多线程设计”来处理并发客户端请求。 - Alex
@Alex:如果你指的是Web服务器,那么我必须说我发现服务器组件几乎从不需要良好的多线程设计,因为数据库通常是同步的单一点,并且因为我从未见过一个Web服务器组件与数据库或编译器相比算法复杂的情况。 - Lothar
10个回答

9
如果您遵循PipelineMap-Reduce设计模式,那就足够了。
将事物分解,以便您可以在操作系统级别的多处理管道中运行。
然后,您可以实际上在实际管道中运行。不需要额外的工作。操作系统处理所有内容。巨大的加速机会。
您还可以切换到线程。需要一些工作。操作系统处理其中的某些部分,线程库处理其余部分。但是,由于您在设计时考虑了“进程”,因此您的线程没有任何令人困惑的数据共享问题。这对于一点点思考来说是一个巨大的胜利。

完全正确 - 不要为“多线程”设计。为数据可以并行处理的高级抽象进行设计。 - Mark Bessey

4

引入线程并不会自动提高性能。


2
确实。如果您查看Web服务器的历史记录,删除线程以支持多路复用I/O是改善性能的第一个重要里程碑之一。 - slebetman
这里有一些证据证明了 slebetman 所指的内容:http://www.kegel.com/c10k.html - Polaris878

3
如果你正在进行一些较为复杂的多线程操作,最好事先考虑并设计好。否则,你的程序要么会完全失败,要么大部分时间能正常工作,但另外一部分时间会做出奇怪的事情。使用多线程编写可证明正确性的程序很难,但这非常重要。因此,我认为良好的多线程设计不是过早优化。

2
他们说写代码的时间可能比设计省时。
并非所有的问题或框架都可以进行多线程处理。例如,您所依赖的库可能不是线程安全的。很多进程天然是顺序的,无法分成可并行化的部分。
而采用多线程/多进程只是一种并发的方式。例如,您也可以使用异步IO。
根据我的经验,从一个单线程转为异步处理比多线程更加明智。但是,我编写的程序解决的问题与其他人的几乎不同。

1
也许好的设计是带有一些特征的系统,这样如果你想引入多线程,你可以优雅地实现它。
我不确定那些特征是什么,但一个类比的例子浮现在我的脑海中:扩展性。如果你为小型操作设计一个无状态系统,你将能够更自然地扩展。
这种事情对我来说似乎很重要。
如果是为了多线程而设计...那么就需要过早地考虑。
如果只是确保一些特征以便在未来进行扩展或多线程处理,那么就不是那么重要了 :)
编辑:哎呀,我再读一遍:过早的优化?
优化:我认为在系统工作正常之前(并且没有因为试图优化而产生缺陷)不要进行优化。做一个干净的设计,最灵活和简单的东西。接下来,当你看到真正需要什么时,你可以进行优化。

1

我相信线程编程也遵循优化的规律。
也就是说,不要浪费时间将快速操作并行化。
相反,将线程应用于执行时间较长的任务。

当然,如果系统开始拥有1000个以上的核心,那么这个答案可能会过时并需要修订。但是再说一遍,如果你想要"完成任务",那么你肯定希望在那之前先发布你的产品。


我同意这个观点,对于像I/O或者非常繁重的计算这样的任务,应该使用线程。你不想在等待一些繁重的I/O时阻塞主线程。 - Polaris878
如果系统开始拥有1000个以上的核心,而且系统有多个核心,“不要浪费时间使快速操作并行化”:这里的“快速”是什么意思?为什么并行化是浪费时间的?除此之外,你还能如何利用今天的处理器,即使在消费市场上也有8个或更多的核心? - Alex
我在这里提到的问题是瓶颈的存在。代码中肯定有一些部分比其他部分执行时间更长。那些部分才是真正需要优化的,也是可能需要使用并行处理的部分。如果您可以使用并行处理将常见但耗时的操作加速10倍,那就去做吧。所谓“浪费时间”,我指的是“浪费开发时间”。此外,上次我检查时,大多数计算机都是2核的,即使4核也相对罕见,8核则非常不寻常。 - luiscubal
"Quick"意味着用户甚至都不会注意到它。如果一个操作已经看起来瞬间完成了,为什么还要让它更快呢? 另外,关于您的8个核心,我很想从可靠的来源获得每个用户当前平均核心数的统计数据。我不是说并行无用。我是说并行在短操作中是无用的。对于长时间的操作,它是有用的。如果一个操作很,那么将它并行化不会有任何优势。即使有8个核心的用户也看不出任何区别。 - luiscubal
帮帮忙!!!别开枪!!!我认为我们只是来自两个不同的背景。关于有趣的用户界面,我同意你的观点。但是,当涉及到更符合我的背景的服务器应用时,我完全不同意你的看法。(我们的最小服务器规格是8核心到16核心)。 - Alex

1

我从不会仅出于性能考虑而为多线程应用程序设计。这是因为使用一些适用于任何应用程序的技术,稍后将操作变成多线程非常容易。我想到的技术包括:

  • 硬常量合约
    • 在C++中,您可以将一个方法标记为const,这意味着它不会改变实例变量的值。您还可以将方法的输入参数标记为const,这意味着只有const方法可以在该参数上调用。通过使用这两种技术(并且不使用“技巧”来规避这些编译器强制执行),您可以缩减需要多线程感知的操作。
  • 依赖反转
    • 这是一种通用技术,其中对象需要的任何外部对象都在构建/初始化时间或作为特定方法的方法签名的一部分传递给它。使用此技术,可以100%确定哪些对象可能会被操作更改(非const实例变量加上操作的非const参数)。知道了这一点,您就知道了操作的非功能方面的范围,并且可以向可能在并行操作之间共享的对象添加互斥锁等。然后,您可以设计正确和高效的并行性。
  • 优先考虑函数式而不是过程式
    • 具有讽刺意味的是,这意味着不要过早地进行优化。使值对象不可变。例如,在C#中,字符串是不可变的,这意味着对它们的任何操作都会返回字符串对象的新实例,而不是现有字符串的修改实例。唯一不应该是不可变的对象是无界数组或包含无界数组的对象,如果这些数组经常被修改。我认为不可变对象更容易理解。许多程序员学习了过程式技术,因此这对我们来说有些陌生,但是当您开始以不可变的术语思考时,先前的程序化编程中可怕的方面,例如操作顺序依赖性和副作用消失了。在多线程编程中,这些方面甚至更加可怕,因此在类设计中使用函数式风格在许多方面都有帮助。随着机器变得越来越快,不可变对象的更高成本变得越来越容易被证明是正确的。今天,这是一个平衡。

1

线程的存在是为了更容易地编写多个代理程序。

  • 如果代理是用户,例如每个用户一个线程,那么它们使编写程序更加容易。这不是性能问题,而是编写难度问题。

  • 如果代理是I/O设备,则可以轻松编写并行执行I/O的程序。这可能是为了提高性能,也可能不是。

  • 如果代理是CPU核心,则可以轻松编写让多个核心并行运行的程序。这时,线程与性能相关。

换句话说,如果您认为线程==并行性==性能,那只是线程的用途之一。


1

有三个基本的设计选择:同步、异步或同步+多线程。如果你是一个疯狂的天才,可以选择一个或多个。

是的,在应用程序设计阶段需要了解客户可接受的性能期望,以便能够在前期做出正确的选择。对于任何非微不足道的项目,将高层次性能期望视为事后补救可能会很危险且耗时。

如果同步不能满足客户要求:

CPU 限制系统需要选择多线程/进程。

IO 限制系统(最常见)通常可以使用异步或 MT。

对于 IO 受限制的问题,利用状态线程等技术可以让您同时拥有蛋糕和吃掉它(同步设计 /w 异步执行)。


0
你在优化和完成任务之间保持平衡的方式是什么?
在实现细节上放松一些,但设计时要留有足够的空间进行优化。这是棘手的部分,但一旦你习惯了就不会感觉那么难了。人们通常会发现自己陷入瓶颈式设计的原因是设计过于细粒度。
例如,以视频处理应用程序为例,其设计围绕着抽象的IPixel展开。这种抽象存在的目的是让软件轻松处理具有不同像素格式的视频,并编写适用于所有像素格式的统一代码。
这样的应用程序在中心设计层面上的性能表现很差,不太可能在编辑、编码、解码和播放方面提供有竞争力的性能,除非进行史诗般的架构重写。这是因为它选择在过于细粒度的层面上进行抽象。
通过选择在像素级别进行抽象,这会在每个像素基础上产生动态分派开销。类比的虚拟指针(或者语言使用的任何东西)允许诸如虚拟分派、运行时类型信息(反射等)等功能,很可能比整个像素本身还要大,将其内存使用量和缓存未命中率翻倍或翻三倍以便顺序处理。此外,如果您想在许多区域中多线程图像处理,您必须重写每个单独处理一个IPixel的地方。
与此同时,如果软件简单地将其抽象设计为更粗略的级别,例如IImage,并避免将单个像素对象暴露给系统的其他部分,所有这些都可以避免。图像实际上是像素的集合(通常是数百万像素),它可以提供一次处理多个像素的操作。现在,对于一个百万像素的图像,与处理像素相关的处理和内存开销降低到1/1,000,000,此时它变得微不足道。这也为图像操作留下了足够的空间来执行像素并行和向量化操作,而无需重写大量代码,因为客户端代码不再单独处理一个像素,而是请求执行整个图像操作。

虽然在图像处理中这似乎是一个不言而喻的事情,因为它本质上是一个非常性能关键的领域,但在其他领域也有很大的发展空间。以您经典的继承示例为例,您不必让Dog继承Mammal。您可以让Dogs继承Mammals

回到完成任务的问题上,我始终保持数据导向的思维方式,但不会在第一次尝试时追求最高效、最友好缓存、最易并行、线程安全、SIMD友好的数据表现形式和尖端的数据结构和算法。否则,我会手握 VTune,看着基准测试越来越快,一整周都在调优(我喜欢这样做,但这绝对不是到处都要做和事先做的有成效的)。我只会投入足够的精力来确定应该使用哪种粒度来设计事物:“我应该让系统依赖于Dog还是Dogs?” 等等。而且,这甚至不需要那么多的思考。对于面向对象编程(OOP),就像这样,“系统每一帧处理十万只狗吗?是/否?”如果是“是”,不要设计一个中央的Dog接口,也不要设计一个中央的IMammal接口。设计Dogs以继承IMammals,就像我们在类比的图像处理场景中避免使用IPixel接口一样,如果我们要同时处理数百万个像素。

数据的大小也应该给你一个提示。如果数据很小,比如说64字节或更少,那么除非它绝对不是性能关键点,否则不应该暴露一个累积依赖的接口。相反,它应该暴露一个集合接口来一次处理多个这样的东西。而如果数据很大,比如说4千字节,那么暴露一个集合接口可能帮助不大,你可能只需要设计一个标量接口,方便一次处理一个这样的东西。

多线程也是同样的道理。你不想在太细粒度的级别上进行锁定,例如,你不想让你的访问模式一直命中共享资源。为了线程安全,你还要能够轻松地推断出哪个线程正在访问代码的哪个状态。为此,你需要一个更粗糙的设计,其中有更多的同质处理,以便你可以轻松地控制和推断出设计本身内存访问模式的情况,最小化对共享资源的访问,避免在太细粒度的级别上进行锁定,甚至可能完全避免锁定。只要你的设计留有足够的空间,你就可以事后取得很大的成就,但关键是要给自己留有足够的空间。

一个微小的东西,被系统中许多不同的事物所依赖,进行非同质处理,这样就没有任何余地了。你可能会遇到类比于只有10米路可用的赛车场景。而一个处理大量微小事物并且以同质方式存储的庞大物体,则留下了无限的优化空间。

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