七、线程相关
7.1 概要
多线程导致的问题也是常见的问题,通常都是因为并发或同步导致的,为了更好的用户体验和对CPU的充分利用,并发和同步是不可或缺的编程模式。因为并发和同步难免会涉及到多线程同时访问同一个资源,或同一段内存,所以问题就从这一初衷开始衍生,常见的问题有:
1. 读脏:读脏的意思是线程访问某个内存区域里面或者某个资源的值在读的时候是一个值,在开始使用的时候又是另外一个值,这是由于可能该资源或者该内存被线程更改,总之一点就是使用某段内存或者资源的值跟该线程期望的结果不一样。读脏的问题涉及到原子性操作的概念,原子性的操作通常形容一个表达式,如果这个表达式对应的机器指令时一条指令那么就可以认为是原子性的操作,参考如下代码:
int x, y; long z; x = 3; // 原子性 z = 10; // 非原子性 y += x; // 非原子性 long local = z; // 非原子性 x++; // 非原子性 |
第一个是给x赋值,这是原子性的操作,因为这仅仅只需要一个mov指令就能完成;给z赋值,在32位CPU是不支持直接将一个值放到占8个字节的内存上,这至少需要两条mov指令;y += x,这个表达式也不是原子性,因为这个操作先获取x和y的值,然后相加,最后把结果赋给y,至少需要两条指令(add, mov);long local = z这个表达式和直接给z赋值是一样的过程;最后x++,从直观上看上去好像是原子性的操作,其实不然,因为x++的操作在编译的时候会被解释成x = x + 1,这个表达式至少需要inc和mov指令。对于原子性的操作永远不会出现线程不安全的情况,例如给x复制的操作,这条指令要么执行,要么没执行,绝不会再执行的过程中出现其他的线程干扰x的操作,但是考虑x++,由于这个表达式需要多条指令执行,所以在执行这个表达式的过程中受到干扰,考虑一下代码:
using System; using System.Threading;
namespace TestAtomicty { class Program { static int _x = 0;
static void IncrementX() { for (int i = 0; i < 100; i++) { Thread.Sleep(1); _x++; } }
static void Main(string[] args) { Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) { threads[i] = new Thread(IncrementX); threads[i].Start(); }
foreach (Thread t in threads) t.Join();
Console.WriteLine(_x); } } } |
最后得到的_x的值不一定是1000.
2. 死锁。死锁的问题比较常见,常见的死锁模型是线程1获得了资源1,然后请求资源2,然而资源2已经被线程2获得了,而且线程2正在请求资源1,这就导致了互等,互不相让,导致死锁,例如:
using System; using System.Threading;
namespace DeadLocker { class Program { static object _locker1 = new object(); static object _locker2 = new object(); static void Request1() { lock (_locker1) { Thread.Sleep(100); lock(_locker2) { Console.Write("Never hit here..."); } } }
static void Request2() { lock (_locker2) { Thread.Sleep(100); lock (_locker1) { Console.WriteLine("Never hit here..."); } } } static void Main(string[] args) { Thread t1 = new Thread(Request1); Thread t2 = new Thread(Request2);
t1.Start(); t2.Start();
t1.Join(); t2.Join();
Console.WriteLine("Never Hit Here..."); } } } |
3. 高速缓存问题。举个例子,著名的单例模式,在Java中通常采用双锁的方式进行编写,如下:
public class Singleton{ private static Singleton _instance = null; private Singleton() {}
public static Singleton GetInstance(){ if (_instance == null) { synchronized(Singleton.class){ if (_instance == null){ _instance = new Singleton(); } } } return _instance; } } |
双锁并不是说使用了两个锁,而是在锁之前判断了是否初始化了类的实例,在锁之后也同样判断了是否初始化了实例,之所以需要两个判断是因为第一次判断到获得锁之间的时间段是有可能出现其他线程访问这个方法并且初始化了示例,所以在加锁之后又判断了一次。这一逻辑看上去完美无缺,但是忽略了CPU硬件的作用,例如两个线程在分别两个CPU上运行这个方法,第一个线程获得并初始化了_instance,第二个线程开始判断,这个时候运行第二个线程的CPU有可能缓存了_instance的值,执行的时候直接从缓存里面拿的值,这个时候判断_instance为null并初始化了另一个Singleton的实例。
4. 拒绝服务。严格的说,这个问题不算是同步导致的问题。比如有个线程池能同时处理的请求个数上限是1000.如果有个操作不停的向线程池申请线程,假设在一个时间点上,该线程池的请求有上万,在这个时候线程池的线程已达上限,并且线程池的执行队列还有很多请求等着执行。如果用户有个操作也涉及到向线程池请求线程,这个时候要轮询到刚刚用户申请的操作需要很长世间或者无限时间,这就导致了拒绝为用户服务。一个常见的网络攻击方式DoS攻击就是这一原理。