背景
我在业余时间开发了一款自己的独立产品:升讯威在线客服与营销系统。陆陆续续开发了几年,从一开始的偶有用户尝试,到如今的 QPS 突破 240 次/秒,连接数突破 4000,日请求数接近 1000 万。(PS:阿里云真贵啊)
在这篇文章中,我将简要介绍我在技术上做了哪些工作,我是如何做到的。
PS:
虽然在线使用是免费的,有条件的用户也可以自行下载私有化部署包,安装部署在自己的服务器使用。(https://kf.shengxunwei.com/)
我做的是个什么产品
先简单看一下这款 QPS 突破 240 次/秒,日请求数接近 1000 万的个人独立产品,它是个啥?直接上图,是一款在线客服系统:
https://kf.shengxunwei.com/
100% 免费下载私有化部署,希望能够打造: 开放、开源、共享。努力打造 .net 社区的一款优秀开源产品。
我是如何做到高 QPS 的?
深入 .NET 多线程技术
死锁和争用条件
多线程处理解决了吞吐量和响应性问题,但引入此功能会带来新的问题:死锁和争用条件。
死锁
两个线程中的每一个线程都尝试锁定另外一个线程已锁定的资源时,就会发生死锁。 两个线程都不能继续执行。
托管线程处理类的许多方法都提供了超时设定,有助于检测死锁。 例如,下面的代码尝试在 lockObject 对象上获取锁。 如果在 300 毫秒内没有获取锁,Monitor.TryEnter 返回 false。
if (Monitor.TryEnter(lockObject, 300)) { try { // Place code protected by the Monitor here. } finally { Monitor.Exit(lockObject); }
}
else { // Code to execute if the attempt times out.
}
争用条件
争用条件是程序的结果取决于两个或更多个线程中的哪一个先到达某一特定代码块时出现的一种 bug。 多次运行程序会产生不同的结果,并且无法预测任何给定运行的结果。
争用条件的一个简单例子是递增一个字段。 假定某个类有一个私有 static 字段(在 Visual Basic 中为 Shared),每创建该类的一个实例时它都递增一次,使用的代码是 objCt++; (C#) 或 objCt += 1 (Visual Basic)。 此操作要求将 objCt 的值加载到寄存器中,使该值递增,然后将其存储到 objCt 中。
在多线程应用程序中,一个已加载并递增该值的线程可能会被另一个线程抢先,抢先的线程执行全部三个步骤;第一个线程继续执行并存储其值时,它会覆盖 objCt,但不考虑该值在其暂停执行期间已更改这一事实。
通过使用 Interlocked 类的方法(如 Interlocked.Increment),可以轻松避免这种争用条件。 若要了解在多个线程间同步数据的其他技巧,请参阅为多线程处理同步数据。
争用条件也可能会在同步多个线程的活动时发生。 编写每一行代码时,都必须考虑出现以下情况时会发生什么情况:一个线程在执行该行代码(或构成该行的任何机器指令)前,其他线程抢先执行了该代码。
静态成员和静态构造函数
在类的类构造函数(C# 中为 static 构造函数、Visual Basic 中为 Shared Sub New)完成运行之前,该类不会初始化。 为防止对未初始化的类型执行代码,在类构造函数完成运行之前,公共语言运行时会禁止从其他线程到类的 static 成员(在 Visual Basic 中为 Shared 成员)的所有调用。
例如,如果某个类构造函数启动了一个新线程,并且该线程过程调用了该类的 static 成员,则在该类构造函数完成之前,会一直禁止新线程。
以上情况适用于可拥有 static 构造函数的任意类型。
建议
使用多线程时需考虑以下准则:
-
不要使用 Thread.Abort 终止其他线程。 对另一个线程调用 Abort 无异于引发该线程的异常,也不知道该线程已处理到哪个位置。
-
不要使用 Thread.Suspend 和 Thread.Resume 同步多个线程的活动。 请使用 Mutex、ManualResetEvent、AutoResetEvent 和 Monitor。
-
不要从主程序中控制工作线程的执行(如使用事件)。 而应设计程序,使工作线程负责等待任务可用,然后执行任务,并在完成时通知程序的其他部分。 如果不阻止工作线程,请考虑使用线程池线程。 Monitor.PulseAll 非常适用于阻止工作线程。
-
不要将类型用作锁定对象。 也就是说,避免一些代码,或避免使用 Monitor.Enter 和 Type 对象。 对于给定类型,每个应用域只有一个 System.Type 实例。 如果锁定对象的类型是“公共的”,那么不属于自己的代码也能锁定该对象,从而导致死锁。 有关其他问题,请参阅可靠性最佳做法。
-
锁定实例时要谨慎,例如,C# 中的 lock(this) 或 Visual Basic 中的 SyncLock(Me)。 如果应用程序中不属于该类型的其他代码锁定了该对象,则会发生死锁。
-
务必确保已进入监视器的线程始终离开该监视器,即使线程在监视器中时发生异常也是如此。 C# 的 lock 语句和 Visual Basic 的 SyncLock 语句可自动提供此行为,同时使用 finally 块来确保调用 Monitor.Exit。 如果无法确保调用 Exit,请考虑将设计更改为使用 Mutex。 Mutex 在当前拥有它的线程终止后会自动释放。
-
务必针对需要不同资源的任务使用多线程,避免向单个资源指定多个线程。 例如,任何涉及 I/O 的任务都会从其拥有自己的线程这一点得到好处,因为此线程在 I/O 操作期间将阻止,从而允许其他线程执行。 用户输入是另一种可从专用线程获益的资源。 在单处理器计算机上,涉及大量计算的任务可与用户输入和涉及 I/O 的任务并存,但多个计算量大的任务将相互竞争。
类库相关建议
为多线程处理设计类库时,考虑以下准则:
-
如果可能,避免同步需求。 对于大量使用的代码更应如此。 例如,可以将一个算法调整为容忍争用情况,而不是完全消除争用情况。 不必要的同步会降低性能,并且可能导致出现死锁和争用情况。
-
默认情况下使静态数据(在 Visual Basic 中为 Shared)是线程安全的。
-
默认情况下不要使实例数据是线程安全的。 通过添加锁来创建线程安全代码会降低性能、加剧锁争用情况,并且可能导致出现死锁。 在常见应用程序模型中,一次只有一个线程执行用户代码,从而最大限度降低线程安全性的需求。 出于此原因,.NET 类库在默认情况下不是线程安全的类库。
-
避免提供可更改静态状态的静态方法。 在常见服务器方案中,静态状态可在各个请求之间共享,这意味着多个线程可同时执行该代码。 这可能导致线程出现 bug。 请考虑使用一种设计模式,将数据封装到在各请求之间不共享的实例中。 此外,如果同步静态数据,更改状态的静态方法间的调用可导致死锁或冗余同步,进而降低性能。
钟意的话请给个赞支持一下吧,谢谢~