自以为是的多线程(二)
上一篇大家已经知道了,线程与线程之间的调度,是不可控的,那当我们去写多线程程序的时候,一定要将线程是乱序的这一点考虑进去,若不然就会出现线程安全问题。
为什么这样讲呢?因为当程序出现多个线程在运行的时候,你无法确定到底是哪一个线程在执行,可能A执行一行代码,这个时候切换到B执行一行代码,然后又切换回A再执行一行代码,这都是有可能出现,不要以为我的代码短,就那么一两行就不需要上锁,多线程程序一定要严谨。
那如何保证严谨呢?
就是当你的程序在使用共享资源的时候,就是当多个线程都有可能调用到同一个变量或是访问同一块内存的时候,一定要保证这段代码的线性执行,比如我有以下代码:
public class DbActionQueue : IDisposable { public Queue<Action> _transQueue; private Thread _thread; private bool _isDispose = false; private static readonly object _syncObject = new object(); private readonly object _syncQueueObject = new object(); private static DbActionQueue _instance; public static DbActionQueue Instance { get { if (_instance == null) { lock (_syncObject) { if (_instance == null) { _instance = new DbActionQueue(); } } } return _instance; } } private DbActionQueue() { if (_transQueue == null) { _transQueue = new Queue<Action>(); } if (_thread == null) { _thread = new Thread(Thread_Work) { IsBackground = true }; } _thread.Start(); } public void Push(Action action) { if (_transQueue == null) throw new ArgumentNullException("dbActionQueue is not init"); lock (_syncQueueObject) { _transQueue.Enqueue(action); } } public void Thread_Work() { while (!_isDispose) { Action[] items = null; if (_transQueue != null && _transQueue.Count > 0) { lock (_syncQueueObject) { items = new Action[_transQueue.Count]; _transQueue.CopyTo(items, 0); _transQueue.Clear(); } } if (items != null && items.Length > 0) { foreach (var item in items) { try { item.Invoke(); } catch (Exception ex) { LogHelper.Write(string.Format("DbActionQueue error. | Exception.StackTrace:{0}", ex.StackTrace), ex); } } } Thread.Sleep(1); } } public void Dispose() { _isDispose = true; _thread.Join(); } }
我在Enqueue的时候上了锁,在Clear的时候也上了锁,这里有一个地方需要说一下,就是当你要对块逻辑进行操作上锁的时候,一定要锁的是同一个对象,否则是没有任何意义的。为什么在这里上锁,假如我不上锁,会有什么问题?
不上锁的情况下,首当其冲的是丢数据问题,当我有一个线程执行完了copyto这行代码以后,有一个线程执行了Enqueue,这个时候,我当前线程会继续跑Clear,就会把Enqueue的数据清理掉,那就相当于丢掉了一条数据。
假如代码稍微变更一下:
while (!_isDispose) { Action item = null; lock (_syncObject) { if (_transQueue != null && _transQueue.Count > 0) { item = _transQueue.Dequeue(); } } item.Invoke(); }
我们会发现,逻辑的执行代码.invoke()放在了lock外面,这个地方上篇博客已经说过了,因为lock会导致的一系列问题,假如我是单条单条的取出的情况下,不上锁可不可以?
不可以的,因为当你一个队列在Enqueue的时候又在跑Dequeue的话,这个队列会出现类似数据库的脏读,幻读等不可预知的bug。不过可以通过换成ConcurrentQueue来解决这个问题,但是有一点要说一下,如果是批量取的情况下,换成ConcurrentQueue依然会出现上述所说的丢数据的问题,因为线程调度不可控,至于ConcurrentQueue的线程安全是通过原子锁还是自旋锁这个并没有特别的文献说明,这里就不做探讨。这里还有一点要说一下,批量取是为了避免频繁的lock,具体一次批量取多少条,你可以自己控制,我这里是一次取完,你可以控制成一次取10条,20条,50条等。
我们会发现因为线程调度不可控这样的一个前提,导致当我们多个线程之间要协作的时候,就会变的异常难以控制,所以在做程序设计的时候,请尽可能的避免多线程协作这种情况发生,如果一定发生了的话,一定不要理所当然的认为自己的代码会按自己的理解执行,给大家举一个例子:
代码大致意思是,有一个网络模块,接收到客户端的消息后,分配某个线程的队列里面去,然后该线程处理完以后,丢给发送线程,核心代码如下:
protected virtual void ReceiveCallback(string ip, int port, string url, bool isLargePack, IntPtr streamHandle, long streamSize, IntPtr bodyData, int bodySize, IntPtr responseHandle) { //初始化一个线程等待事件(信号灯) AutoResetEvent autoEvent = null; //开启异步处理的情况下(因为这个模块支持同步和异步) if (!this._isSync) { autoEvent = new AutoResetEvent(false); } //从streamHandler里面读取数据 var data = Read2Byte(streamHandle, bodyData, streamSize, bodySize, isLargePack); //转换成内部协议数据(Bson) var obj = BsonHelper.ToObject<Communication>(data); //一个Action<Communication, IntPtr, object> if (Received != null) { Received.Invoke(obj, responseHandle, autoEvent); } //阻塞,一直到收到信号 if (autoEvent != null) { autoEvent.WaitOne(this._timeOut); } }
Receive.Invoke 这个地方Receive是一个Action,代码如下:
public void InvokeCommand(Communication obj, IntPtr connect, object e) { //数据完整性判断 if (obj == null || string.IsNullOrEmpty(obj.Command)) { obj = new Communication { Command = "ErrorCommand", Body = new Newtonsoft.Json.Linq.JObject() }; obj.Body["Token"] = Guid.NewGuid().ToString(); } var unit = new InternelUnit { Event = e, Packet = obj, Connection = connect }; //是否同步 if (this._isSync) { this.RequestCallBack(unit); } else { //放入业务处理队列 RequestQueueManage.Instance.Push(unit); } }
这两段代码的意思是,网络模块接受到消息以后,丢给线程队列。那由于生存周期控制,导致RequestHandler这个句柄,只在这个方法体里面有效,如果该方法体结束,则句柄被释放。于是我们就有了,Push到线程队列里面以后,做了一个信号的WaitOne的处理。就是希望等到发送线程处理完以后,再释放这个信号,代码如下:
public void ResponseCallBack(InternelUnit unit) { //该包是否要入丢包池 if (unit.IsInLastPackPool) { Core.LostPacketPool.LostPacketPool.Instance.Push(ref unit); } //按协议转换成byte[] var repBson = BsonHelper.ToBson(unit.Packet); //是否开启加密 if (this._isEncrypt) { repBson = EncryptHelper.Decrypt(repBson, repBson.Length); } //发送 Network.NetworkHelper.Send(unit.Connection, repBson, unit.Id); //是否开启异步 if (!_isSync) { //释放信号 (unit.Event as System.Threading.AutoResetEvent).Set(); } }
这整段代码,在大部分情况下是不会有问题的,但是由于刚刚我们说到的,线程调度不可控,于是我们无法保证,在Receive.Invoke()以后,代码继续向下执行,执行了WaitOne(),如果在Receive.Invoke以后,程序就切换到了,业务处理线程,那就有可能出现,先执行了Set()释放了信号,然后再执行WaitOne(),就会出现死锁,不过好在我们有做超时控制,并不会出现绝对的死锁(不过也相差无几了)。
所以这段程序这样写,就是一个不严谨的程序,会出现很多莫名其妙的超时。那当程序确实需要多线程之间协作的时候,请尽可能的用callback的方式来进行处理,而且控制好生命周期,尽可能的避免资源得不到释放。
再举个比较常见的投票的例子:
//从缓存获取文章对象 var article = CacheHelper.Get(articleid); 给点赞的+1 article.Up++ 写回缓存,由于引用技术关系,所以如果缓存是你自己控制在你的程序内部的话(比如Dictionary),这一步是可以省略的。 //CacheHelper.Set(articleid, article);
很简单的一个计数器的代码,但是由于当多个用户同时点赞的话,程序就有可能把数据加错(原因不再赘述)。于是我们便有了加lock的打算,代码如下:
lock(object){ 投票计数器+1 }
这里有一个地方要注意,就是如果功底不够的话,尽量不要lock(this),因为这里的this指的是当前实例,而多个线程里面可能会有多个实例,那么lock的就不是同一个对象了。
这个时候你的代码看起来就没啥问题了,可是如果你的程序是部署在多台机器上面的,那么数据加错的问题就依然会出现,对吧。因为两台机器上面lock的并不是同一个对象,这个时候可能就需要使用DB,或者是引入一个第三方的中间件(例如redis等),需要有一个地方作为一个唯一的中心控制,你才能保证数据的一致性,那还有一种做法,就是对articleid取模,让同一片文章点赞的操作,转到同一台机器上面去操作这样也可。
同理,当我们在做DB到缓存的处理时,也是这样,比如我们有以下代码,
var list = CacheHelper.Get(key); if(list == null){ list = GetListFromDB(xxx); } return list;
这一段代码的问题就是,当GetListFromDB()的时候,数据发生了变化,那可能多台机器拿到的list,就会不一样。你可能就需要做一些定时同步的处理了。如果多个线程一直读的时候,又会出现,多个线程同时去DB拿数据的情况发生,这不是我们想看到的,于是我们便加Lock
var list = CacheHelper.Get(key); if(list == null){ lock(object){ list = CacheHelper.Get(key); if(list == null){ list = GetListFromDB(xxx); } } } return list;
为什么会有双重判断?因为在你lock的时候,之前的那个线程可能已经读取到数据了,这样就可以避免当多个线程运行到这里的时候,由于已经判断了的原因,导致多个线程依然去DB取数据。由于从DB取数据比较缓慢,所以这里依然会有像我们上篇所讲到的那样,不断的线程调度,锁定,切换的这样一个循环。所以尽量慎用lock。
线程安全的问题主要就是线程调度不可控的问题,我们需要尽可能的保证自己对共享资源处理的地方是block的,能够线性执行。