本文主要针对 gkarch 相关文章留作笔记,仅在原文基础上记录了自己的理解与摘抄部分片段。
遵循原作者的 cc 3.0 协议。
如果想要了解更加详细的文章信息内容,请访问下列地址进行学习。原文章地址:
同步构造基本分为四种,简单的阻塞方法、锁构造、信号构造、非阻塞同步构造。
sleep()
与 join()
方法。当一个线程被阻塞的时候,会立即出让(yields) cpu 时间片,不再消耗处理器时间。threadstate
属性来确认某个线程是否被阻塞。thread.interrupt()
中断。thread.abort()
中止。信号构造与锁构造可以在某些条件被满足前阻塞线程。另外一种方法就是通过自旋来等待条件被满足。
自旋即通过一个循环不断检测条件,来伪造一个空忙状态。
虽然自旋会造成大量的处理器时间浪费,但是它可以避免上下文切换带来的额外开销。
一个标准的自旋结构如下列代码。
// 单纯的自旋 while(!proceed); // 阻塞 + 自旋 while(!proceed) thread.sleep(10);
排它锁的作用是为了保证线程安全,如下列代码。如果 go()
方法被两个线程同时执行,则可能某个线程在执行完 if
后,另一个线程已经将 v2
置为0,原先线程就可能造成除数不能为 0 的异常。
class code1 { static int v1 = 1,v2 = 1; static void go() { if(v2 != 0) console.writeline(v1 / v2); v2 = 0; } } // 使用了排它锁的代码 class code2 { static int v1 = 1,v2 = 1; static readonly object locker = new object(); static void go() { lock(locker) { if(v2 != 0) console.writeline(v1 / v2); v2 = 0; } } }
如果使用了 lock
语句快,则可以锁定一个同步对象,其他竞争锁的线程会被阻塞,直到锁被释放。
如果有多个线程竞争锁,则按照先到先得的队列进行排队,通过排它锁可以强制线程对锁保护的内容进行顺序访问。
在竞争锁时被阻塞的线程,其状态为 waitsleepjoin
。
不同的同步结构技术的性能开销。
构造 | 用途 | 开销 |
---|---|---|
lock ( monitor.enter / monitor.exit ) |
确保同一时间只有一个线程可以访问资源或代码 | 20 ns |
mutex | 确保同一时间只有一个线程可以访问资源或代码 | 1000 ns |
semaphoreslim | 确保只有不超过指定数量的线程可以并发访问资源或代码 | 200 ns |
semaphore | 确保只有不超过指定数量的线程可以并发访问资源或代码 | 1000 ns |
readerwriterlockslim | 允许多个读线程和一个写线程共存 | 40 ns |
readerwriterlock (已过时) |
允许多个读线程和一个写线程共存 | 100 ns |
lock
语句块实质上就是一个语法糖,其核心代码就是结合 try/finally
来调用 monitor.enter()
与 monitor.exit()
方法,并且如果在一个方法内直接调用 monitor.exit()
会直接抛出异常。
monitor.enter(locker); try { if(v2 != 0) console.writeline(v1 / v2); v2 = 0; } finally { monitor.exit(locker); }
上述情况可能发生锁泄漏,因为在 monitor.enter()
与 try/finally
语句块之间如果发生了异常,会导致后续的 try/finally
语句块不被执行。造成无法获得锁,或者得到锁之后,无法释放造成锁泄漏。
解决锁泄漏的方式是,clr 4.0 当中,对于 lock
语句的翻译则是通过一个 bool
类型的 locktaken
进行解决。
bool locktaken = false; try { monitor.enter (locker, ref locktaken); // 用户代码 ... } finally { if(locktaken) { monitor.exit(locker); } }
monitor
还提供了 tryenter()
方法,用于执行超时时间,如果超过时间没有获得到锁,则返回 false
。
需要访问任意可写的共享字段,下面代码展示了线程安全与非线程安全的代码。
class threadunsafe { static int _x; static void increment() { _x++; } static void assign() { _x = 123; } } // 线程安全 class threadsafe { static readonly object _locker = new object(); static int _x; static void increment() { lock(_locker) _x++; } static void assign() { lock(_locker) _x++; } }
lock(locker) { if(x != 0) y /= x; }
就可以说 x
与 y
是被原子访问的,因为这段代码无法被其他线程分割或者抢占。lock
锁内抛出异常,将会影响锁的原子性,这个时候就需要结合回滚机制来进行实现。lock
语句处被阻塞。死锁是当两个甚至多个线程所等待的资源都被对方占用的时候,它们都无法执行,就会产生了死锁。
一个标准的死锁代码如下,我们在 a 线程内部锁定了 locker1
与 locker2
,在主线程同同时也锁定了 locker2
与 locker1
。这个时候由于排他锁的特性,主线程与新开启的线程都会等待对方的锁被释放,造成死锁。
object locker1 = new object(); object locker2 = new object(); new thread(() => { lock(locker1) { thread.sleep(1000); lock(loekcer2); } }).start(); lock(locker2) { thread.sleep(); lock(locker1); }
应该尽量较少对锁的使用,更多的依靠其他的同步构造进行处理。
waitone()
方法进行加锁,使用 releasemutex()
来解锁。mutex
对象会自动释放锁,所以可以结合 using
语句块进行使用。lock
慢约 50 倍。信号量具有一定容量,当容量满了之后和就会拒绝其他线程占用,当有一个线程释放资源之后,其他线程按先后顺序进入。
class program { static void main(string[] args) { var sem = new semaphore(); for (int i = 1; i <= 5; i++) new thread(sem.enter).start(i); } } public class semaphore { private readonly semaphoreslim _semaphoreslim = new semaphoreslim(3); public void enter(object id) { console.writeline($"id 为 {id} 的线程想调用本方法。"); _semaphoreslim.wait(); console.writeline($"id 为 {id} 的线程已经进入方法。"); thread.sleep(1000 * (int)id); console.writeline($"id 为 {id} 的线程正在离开方法。"); _semaphoreslim.release(); } }
容量为 1 的信号量与 mutex
和 lock
类似。
信号量是线程无关的,任何线程都可以调用 release()
方法释放信号量。而 mutex
与 lock
只有获得锁的线程才可以释放。
在 .net 4.0 当中有一个轻量级的信号量 semaphoreslim
,但是不是跨进程的,开销只有 semaphore
的四分之一。
一般在某些需要限流或者是要执行比较密集的磁盘 i/o 操作,这个时候可以使用信号量进行并发限制,这样可以改善程序整体的性能。
contextboundobject
类并且使用 synchronization
特性。但是这种方法很容易造成死锁的情况,并且降低并发度。通过锁可以将不安全的代码转换为线程安全的代码,例如 bcl 提供的 list<t>
集合本身不是线程安全的,但是通过对一个集合实例的锁定,我们就可以进行线程安全的操作。下面的代码当中,我们直接使用 list<int>
集合自身来加锁,这里对集合进行遍历的操作也不是线程安全的,也需要加锁进行处理,另一种方式就是通过读写锁来实现避免长时间锁定。
class program { static void main(string[] args) { var bcl = new bclthreadsafe(); for (int i = 0; i < 10; i++) { new thread(bcl.additem).start(); } } } public class bclthreadsafe { private readonly list<int> _innerlist = new list<int>(); public void additem() { lock (_innerlist) { _innerlist.add(_innerlist.count); } var sb = new stringbuilder(); lock (_innerlist) { foreach (var item in _innerlist) { sb.append(item).append(','); } } console.writeline(sb.tostring().trimend(',')); } }
即便 list<t>
集合是线程安全的,如果我们需要使用以下代码增加一个新的数据到集合当中。也会由于在执行 if
之后,其他线程抢占修改了 _list
集合,增加了一个相同的类目。在这个时候,对 _list
集合的添加操作就是存在问题的。
if(!_list.contains(newitem)) _list.add(newitem);
在高并发的环境下,对集合的访问加锁可能产生大量阻塞,所以进行类似操作的时候建议使用线程安全的队列、栈、字典。
针对于静态成员,bcl 的所有类型的静态成员都实现了线程安全,所以开发人员在开发基础类型或者框架的时候,应该保证静态成员的线程安全。
大部分 bcl 类型的只读访问都是线程安全的,开发人员在设计类基础类型或者框架的时候也应该遵循这个规则。
服务端经常需要使用到多线程处理客户请求,也就意味着必须考虑线程安全。但一般来说服务端类都是无状态的,或者为每个请求创建新的对象实例,很少存在有交互的点。
以缓存为例,假设对一个用户表使用了静态的字典实例进行缓存,那么就存在线程安全的问题。下列代码在读取与更新锁的时候,使用了排它锁进行加锁处理。但是会存在两个线程同时访问 getuser()
方法的时候,都传递了未缓存过得数据的 id
,这个时候就会去查询两次数据库。虽然可以通过对整个 getuser()
加锁,但是这样设计的话,都会在 queryuser()
进行查询的时候,整个获得用户信息的方法都被阻塞。
static class usercache { static dictionary<int,user> _users = new dictionary<int,user>(); internal static user getuser(int id) { user u = null; lock(_users) { if(_users.trygetvalue(id,out u)) { return u; } } // 从数据库查询用户数据 u = queryuser(id); lock(_users)_users[id] = u; return u; } }
富客户端程序一般都是基于 dependencyobject
(wpf) 与 control
(windows forms),它们都具备线程亲和性,即只有创建他们的线程才能够访问其成员。
作用就是访问 ui 对象并不需要加锁,坏处则是如果要跨线程调用 ui 控件则需要一些比较繁琐的步骤。
dispatcher
调用 invoke()
或 begininvoke()
。control()
对象的 invoke()
或 begininvoke()
。invoke()
与 begininvoke()
都接收一个委托以便代替工作线程需要在 ui 线程执行的操作。前者是同步方法,在委托执行完成之前,都处于阻塞状态。后者是异步方法,调用方立刻返回。
// wpf demo public partial class mainwindow : window { public mainwindow() { initializecomponent(); new thread(work).start(); } private void work() { thread.sleep(5000); // 阻塞当前线程 5s 模拟耗时任务 updatemessage("new msg"); } private void updatemessage(string msg) { var action = () => txtmessage.text = msg; dispatcher.invoke(action); } } // windows forms demo public partial class formclass : form { // ... 其他代码 private void updatemessage(string msg) { var action = () => txtmessage.text = msg; this.invoke(action); } // ... 其他代码 }
事件等待句柄的作用是用于进行信号同步。
信号同步即一个线程进行等待,直到其接受到其他线程通知的过程。
信号构造的开销比较。
构造 | 用途 | 开销 |
---|---|---|
autoresetevent | 使线程在接收到其他线程信号时解除阻塞一次。 | 1000 ns |
manualresetevent | 使线程在接收到其他线程信号时解除阻塞,并不继续 阻塞,直到其复位。 |
1000 ns |
manualreseteventslim | 使线程在接收到其他线程信号时解除阻塞,并不继续 阻塞,直到其复位。 |
40 ns |
countdownevent | 使线程在收到预订数量的信号时,解除阻塞。 | 40 ns |
barrier | 实现线程执行屏障。 | 80 ns |
wait 和 pulse | 使线程阻塞,直到自定义条件被满足。 | pulse/120 ns |
autoresetevent
的原理类似于验票闸机,在闸机处调用 waitone()
方法,线程就会被阻塞。插入票的动作就类似于调用 set()
方法打开闸机。任何能够访问这个 autoresetevent
的非阻塞线程都可以调用 set()
方法来放行一个被阻塞的线程。
autoresetevent
是基于 eventwaithandle
进行构造的,有两种方法可以创建 autoresetevent
对象。第一种即通过其构造方法 var auto = new autoresetevent (false);
,第二种则是通过 eventwaithandle
传递事件类型,var auto = new eventwaithandle (false, eventresetmode.autoreset);
。这里如果传递的是 false
则会在创建后立即调用 set()
方法。
class program { public static readonly eventwaithandle waithandle = new autoresetevent(false); static void main(string[] args) { var testclass = new autoreseteventtest(); new thread(testclass.waiter).start(); // 主线程等待 1 秒再发送信号唤醒 thread.sleep(1000); waithandle.set(); } } public class autoreseteventtest { public void waiter() { console.writeline("线程开始等待..."); // 如果传入了超时时间,超时则返回 false。 program.waithandle.waitone(); console.writeline("接受到了通知,进入闸机。"); } }
如果没有线程等待的时候调用 set()
方法,则等待句柄会保持初始状态,直到有线程调用了 waitone()
方法。
为等待句柄调用 reset()
方法可以关闭闸机,这个方法不会被阻塞。
可以调用 dispose()
方法来销毁等待句柄,或者直接丢弃,等待 gc 进行回收。
如果主线程需要向工作线程连续发送 3 个信号并结束线程,则可以通过双向信号进行实现,其步骤大体如下。
null
,工作线程进行退出。static void main(string[] args) { var testobj = new multiautoreseteventtest(); new thread(testobj.workthread).start(); multiautoreseteventtest.waithandle_mainthread.waitone(); lock (multiautoreseteventtest.locker) multiautoreseteventtest.message = datetime.now.tofiletimeutc().tostring(); multiautoreseteventtest.waithandle_workthread.set(); multiautoreseteventtest.waithandle_mainthread.waitone(); lock (multiautoreseteventtest.locker) multiautoreseteventtest.message = datetime.now.tofiletimeutc().tostring(); multiautoreseteventtest.waithandle_workthread.set(); multiautoreseteventtest.waithandle_mainthread.waitone(); lock (multiautoreseteventtest.locker) multiautoreseteventtest.message = datetime.now.tofiletimeutc().tostring(); multiautoreseteventtest.waithandle_workthread.set(); multiautoreseteventtest.waithandle_mainthread.waitone(); lock (multiautoreseteventtest.locker) multiautoreseteventtest.message = null; multiautoreseteventtest.waithandle_workthread.set(); } } public class multiautoreseteventtest { public static readonly eventwaithandle waithandle_mainthread = new autoresetevent(false); public static readonly eventwaithandle waithandle_workthread = new autoresetevent(false); public static string message = string.empty; public static readonly object locker = new object(); public void workthread() { while (true) { waithandle_mainthread.set(); waithandle_workthread.waitone(); lock (locker) { if (message == null) return; console.writeline($"收到主线程的消息,内容为: {message}"); } } } }
生产消费者队列的构成如下所描述的一致。
生产/消费者队列可以精确控制工作线程的数量,clr 的线程池就是一种生产/消费者队列。
结合 autoresetevent
事件等待句柄,我们可以很方便地实现一个生产/消费者队列。
class program { static void main(string[] args) { using (var queue = new producerconsumerqueue()) { queue.enqueuetask("hello"); for (int i = 0; i < 10; i++) { queue.enqueuetask($"{i}"); } queue.enqueuetask("end"); } } } public class producerconsumerqueue : idisposable { private readonly eventwaithandle _waithandle = new autoresetevent(false); private readonly object _locker = new object(); private readonly queue<string> _taskqueue = new queue<string>(); private readonly thread _workthread; public producerconsumerqueue() { _workthread = new thread(work); _workthread.start(); } public void enqueuetask(string task) { // 向队列当中插入任务,加锁保证线程安全 lock (_locker) { _taskqueue.enqueue(task); } // 通知工作线程开始干活 _waithandle.set(); } private void work() { while (true) { string task = null; lock (_locker) { if (_taskqueue.count > 0) { task = _taskqueue.dequeue(); if (task == null) return; } } if (task != null) { thread.sleep(100); console.writeline($"正在处理任务 {task}"); } else { // 如果任务等于空则阻塞线程,等待心的工作项 _waithandle.waitone(); } } } public void dispose() { // 优雅退出 enqueuetask(null); _workthread.join(); _waithandle.close(); } }
.net 4.0 以后提供了一个 blockingcollection<t>
类型实现了生产/消费者队列。
与 autoresetevent
类似,但在调用 set()
方法的时候打开门,是可以允许任意数量的线程在调用 waitone()
后通过。(与 autoresetevent
每次只能通过 1 个不一样)
如果是在关闭状态下调用 waitone()
方法,线程会被阻塞,其余功能都与 autoresetevent
一致。
manualresetevent
的基类也是 eventwaithandle
,通过以下两种方式均可构造。
var manual1 = new manualresetevent(false); var manual2 = new eventwaithandle(false, eventresetmodel.manualreset);
.net 4.0 提供了性能更高的 manualreseteventsliam
,但是不能够跨线程使用。
使用 countdownevent
可以指定一个计数器的值,用于表明需要等待的线程数量。
调用 signal()
方法会将计数器自减 1 ,如果调用其 wait()
则会阻塞计数到 0 ,通过 addcount()
可以增加计数。
class program { static void main() { var test = new countdowneventtest(); new thread(test.say).start("hello 1"); new thread(test.say).start("hello 2"); new thread(test.say).start("hello 3"); test.countdownevent.wait(); console.writeline("所有线程执行完成..."); } } public class countdowneventtest { public readonly countdownevent countdownevent = new countdownevent(3); public void say(object info) { thread.sleep(1000); console.writeline(info); countdownevent.signal(); } }
当计数为 0 的时候,无法通过 addcount()
增加计数,只能调用 reset()
进行复位。
除了手动开启线程之外,事件等待句柄也支持通过线程池来运行工作任务。
通过 threadpool.registerwaitforsingleobject()
方法可以减少资源消耗,当需要执行的委托处于等待状态的时候,不会浪费线程资源。
class program { static void main() { var test = new threadpooltest(); test.test(); } } public class threadpooltest { private readonly eventwaithandle _waithandle = new manualresetevent(false); public void test() { registeredwaithandle reghandle = threadpool.registerwaitforsingleobject(_waithandle, work, "ojbk", -1, true); thread.sleep(1000); _waithandle.set(); console.readline(); reghandle.unregister(_waithandle); } public void work(object data,bool timeout) { console.writeline($"正在执行任务 {data} ....."); } }
上述代码如果通过传统的方式进行阻塞与信号发送, 那么有 1000 个请求 work()
方法,就会造成大量服务线程阻塞,而 registerwaitforsingleobject
可以立即返回,不会浪费线程资源。
可以通过对 eventwaithandle
类型构造函数的第三个参数传入标识,来获得跨进程的事件的等待句柄。
eventwaithandle wh = new eventwaithandle(false,eventresetmode.autoreset,"appname.identity");
synchronizationcontext
类,而是 clr 的自动锁机制。contextboundobject
基类并添加 synchronization
特性即可让 clr 自动加锁。synchronization
特性的 reentrant
参数设置为 true
。则允许同步类是可被重入的,这就导致同步上下文被临时释放,会导致过度期间任何线程都可以自由调用原对象的任何方法。synchronization
特性是直接作用于类,所以其所有方法都会带来可重入的问题。
如对本文有疑问, 点击进行留言回复!!
《CTF特训营》web部分读书笔记(二)跨站脚本攻击(XSS)
国密SM1\ SM2\ SM3\ SM4\ SSF33算法和国际RSA算法的对应关系
网友评论