设为首页 加入收藏

TOP

解决多线程代码中的 11 个常见的问题(三)
2011-12-24 22:53:38 】 浏览:4285
Tags:解决 线程 代码 常见 问题
 

锁保护
当某个锁的到达率与其锁获得率相比始终居高不下时,可能会产生锁保护。在极端的情况下,等待某个锁的线程超过了其承受力,就会导致灾难性后果。对于服务器端的程序而言,如果客户端所需的某些受锁保护的数据结构需求量大增,则经常会发生这种情况。
例如,请设想以下情况:平均来说,每 100 毫秒会到达 8 个请求。我们将八个线程用于服务请求(因为我们使用的是 8-CPU 计算机)。这八个线程中的每一个都必须获得一个锁并保持 20 毫秒,然后才能展开实质的工作。
遗憾的是,对这个锁的访问需要进行序列化处理,因此,全部八个线程需要 160 毫秒才能进入并离开锁。第一个退出后,需要经过 140 毫秒第九个线程才能访问该锁。此方案本质上无法进行调整,因此备份的请求会不断增长。随着时间的推移,如果到达率不降低,客户端请求就会开始超时,进而发生灾难性后果。
众所周知,在锁中是通过公平性对锁进行保护的。原因在于在锁本来已经可用的时间段内,锁被人为封闭,使得到达的线程必须等待,直到所选锁的拥有者线程能够唤醒、切换上下文以及获得和释放该锁为止。为解决这种问题,Windows 已逐渐将所有内部锁都改为不公平锁,而且 CLR 监视器也是不公平的。
对于这种有关保护的基本问题,唯一的有效解决方案是减少锁持有时间并分解系统以尽可能减少热锁(如果有的话)。虽然说起来容易做起来难,但这对于可伸缩性来说还是非常重要的。

“蜂拥”是指大量线程被唤醒,使得它们全部同时从 Windows 线程计划程序争夺关注点。例如,如果在单个手动设置事件中有 100 个阻塞的线程,而您设置该事件…嗯,算了吧,您很可能会把事情弄得一团糟,特别是当其中的大部分线程都必须再次等待时。
实现阻塞队列的一种途径是使用手动设置事件,当队列为空时变为无信号而在队列非空时变为有信号。遗憾的是,如果从零个元素过渡到一个元素时存在大量正在等待的线程,则可能会发生蜂拥。这是因为只有一个线程会得到此单一元素,此过程会使队列变空,从而必须重置该事件。如果有 100 个线程在等待,那么其中的 99 个将被唤醒、切换上下文(导致所有缓存丢失),所有这些换来的只是不得不再次等待。

两步舞曲
有时您需要在持有锁的情况下通知一个事件。如果唤醒的线程需要获得被持有的锁,则这可能会很不凑巧,因为在它被唤醒后只是发现了它必须再次等待。这样做非常浪费资源,而且会增加上下文切换的总数。此情况称为两步舞曲,如果涉及到许多锁和事件,可能会远远超出两步的范畴。
Win32 和 CLR 的条件变量支持在本质上都会遇到两步舞曲问题。它通常是不可避免的,或者很难解决。
两步舞曲问题在单处理器计算机上情况更糟。在涉及到事件时,内核会将优先级提升应用到唤醒的线程。这几乎可以保证抢先占用线程,使其能够在有机会释放锁之前设置事件。这是在极端情况下的两步舞曲,其中设置 ThreadA 已切换出上下文,使得唤醒的 ThreadB 可以尝试获得锁;当然它无法做到,因此它将进行上下文切换以使 ThreadA 可再次运行;最终,ThreadA 将释放锁,这将再次提升 ThreadB 的优先级,使其优先于 ThreadA,以便它能够运行。如您所见,这涉及了多次无用的上下文切换。

优先级反转
修改线程优先级常常是自找苦吃。当不同优先级的许多线程共享对同样的锁和资源的访问权时,可能会发生优先级反转,即较低优先级的线程实际无限期地阻止较高优先级线程的进度。这个示例所要说明的道理就是尽可能避免更改线程优先级。
下面是一个优先级反转的极端示例。假设低优先级的 ThreadA 获得某个锁 L。随后高优先级的 ThreadB 介入。它尝试获得 L,但由于 ThreadA 占用使得它无法获得。下面就是“反转”部分:好像 ThreadA 被人为临时赋予了一个高于 ThreadB 的优先级,这一切只是因为它持有 ThreadB 所需的锁。
当 ThreadA 释放了锁后,此情况最终会自行解决。遗憾的是,如果涉及到中等优先级的 ThreadC,设想一下会发生什么情况。虽然 ThreadC 不需要锁 L,但它的存在可能会从根本上阻止 ThreadA 运行,这将间接地阻止高优先级 ThreadB 的运行。
最终,Windows Balance Set Manager 线程会注意到这一情况。即使 ThreadC 保持永远可运行状态,ThreadA 最终(四秒钟后)也将接收到操作系统发出的临时优先级提升指令。但愿这足以使其运行完毕并释放锁。但这里的延迟(四秒钟)相当巨大,如果涉及到任何用户界面,则应用程序用户肯定会注意到这一问题。

实现安全性的模式
现在我已经找出了一个又一个的问题,好消息是我这里还有几种设计模式,您可以遵循它们来降低上述问题(尤其是正确性危险)的发生频率。大多数问题的关键是由于状态在多个线程之间共享。更糟的是,此状态可被随意控制,可从一致状态转换为不一致状态,然后(但愿)又重新转换回来,具有令人惊讶的规律性。
当开发人员针对单线程程序编写代码时,所有这些都非常有用。在您向最终的正确目标迈进的过程中,很可能会使用共享内存作为一种暂存器。多年来 C 语言风格的命令式编程(www.cppentry.com)语言一直使用这种方式工作。
但随着并发现象越来越多,您需要对这些习惯密切加以关注。您可以按照 Haskell、LISP、Scheme、ML 甚至 F#(一种符合 .NET 的新语言)等函数式编程(www.cppentry.com)语言行事,即采用不变性、纯度和隔离作为一类设计概念。

不变性
具有不变性的数据结构是指在构建后不会发生改变的结构。这是并发程序的一种奇妙属性,因为如果数据不改变,则即使许多线程同时访问它也不会存在任何冲突风险。这意味着同步并不是一个需要考虑的因素。
不变性在 C++(www.cppentry.com) 中通过 const 提供支持,在 C# 中通过只读修饰符支持。例如,仅具有只读字段的 .NET 类型是浅层不变的。默认情况下,F# 会创建固定不变的类型,除非您使用可变修饰符。再进一步,如果这些字段中的每个字段本身都指向字段均为只读(并仅指向深层不可变类型)的另一种类型,则该类型是深层不可变的。这将产生一个保证不会改变的完整对象图表,它会非常有用。
所有这一切都说明不变性是一个静态属性。按照惯例,对象也可以是固定不变的,即在某种程度上可以保证状态在某个时间段不会改变。这是一种动态属性。Windows Presentation Foundation (WPF) 的可冻结功能恰好可实现这一点,它还允许在不同步的情况下进行并行访问(但是无法以处理静态支持的方式对其进行检查)。对于在整个生存期内需要在固定不变和可变之间进行转换的对象来说,动态不变性通常非常有用。
不变性也存在一些弊端。只要有内容需要改变,就必须生成原始对象的副本并在此过程中应用更改。另外,在对象图表中通常无法进行循环(除动态不变性外)。
例如,假设您有一个 ImmutableStack<T>,如图 4 所示。您需要从包含已应用更改的对象中返回新的 ImmutableStack<T> 对象,而不是一组变化的 Push 和 Pop 方法。在某些情况下,可以灵活使用一些技巧(与堆栈一样)在各实例之间共享内存。
 
public class ImmutableStack<T> { private readonly T m_value; private readonly ImmutableStack<T> m_next; private readonly bool m_empty; public ImmutableStack() { m_empty = true; } internal ImmutableStack(T value, Node next) { m_value = value; m_next = next; m_empty = false; } public ImmutableStack<T> Push(T value) { return new ImmutableStack(value, this); } public ImmutableStack<T> Pop(out T value) { if (m_empty) throw new Exception("Empty."); return m_next; } }
节点被推入时,必须为每个节点分配一个新对象。在堆栈的标准链接列表实现中,必须执行此操作。但是要注意,当您从堆栈中弹出元素时,可以使用现有的对象。这是因为堆栈中的每个节点是固定不变的。
固定不变的类型无处不在。CLR 的 System.String 类是固定不变的,还有一个设计指导原则,即所有新值类型都应是固定不变的。此处给出的指导原则是在可行和合适的情况下使用不变性并抵抗执行变化的诱惑,而最新一代的语言会使其变得非常方便。
 
首页 上一页 1 2 3 4 下一页 尾页 3/4/4
】【打印繁体】【投稿】【收藏】 【推荐】【举报】【评论】 【关闭】 【返回顶部
上一篇大文件重定向和管道的效率对比 下一篇Linux下多路复用IO接口 epoll sel..

最新文章

热门文章

Hot 文章

Python

C 语言

C++基础

大数据基础

linux编程基础

C/C++面试题目