C# 线程并发锁

本文目录:

  • 线程的简单使用
  • 并发和异步的区别
  • 并发控制 - 锁
  • 线程的信号机制
  • 线程池中的线程
  • 案例:支持并发的异步日志组件

线程的简单使用

常见的并发和异步大多是基于线程来实现的,所以本文先讲线程的简单使用方法。

使用线程,我们需要引用System.Threading命名空间。创建一个线程最简单的方法就是在 new 一个 Thread,并传递一个ThreadStart委托(无参数)或ParameterizedThreadStart委托(带参数),如下:

复制代码
class Program {
    static void Main(string[] args) {

        // 使用无参数委托ThreadStart
        Thread t = new Thread(Go);
        t.Start();

        // 使用带参数委托ParameterizedThreadStart
        Thread t2 = new Thread(GoWithParam);
        t2.Start("Message from main.");

        t2.Join();// 等待线程t2完成。

        Console.WriteLine("Thread t2 has ended!");
        Console.ReadKey();
    }

    static void Go() {
        Console.WriteLine("Go!");
    }

    static void GoWithParam(object msg) {
        Console.WriteLine("Go With Param! Message: " + msg);
        Thread.Sleep(1000);// 模拟耗时操作
    }
}
复制代码

运行结果:

线程的用法,我们只需要了解这么多。下面我们再来通过一段代码来讲讲并发和异步。

并发和异步的区别

关于并发和异步,我们先来写一段代码,模拟多个线程同时写1000条日志:

复制代码
class Program {
    static void Main(string[] args) {

        Thread t1 = new Thread(Working);
        t1.Name = "Thread1";
        Thread t2 = new Thread(Working);
        t2.Name = "Thread2";
        Thread t3 = new Thread(Working);
        t3.Name = "Thread3";

        // 依次启动3个线程。
        t1.Start();
        t2.Start();
        t3.Start();

        Console.ReadKey();
    }

    // 每个线程都同时在工作
    static void Working() {
        // 模拟1000次写日志操作
        for (int i = 0; i < 1000; i++) {
            //  异步写文件
            Logger.Write(Thread.CurrentThread.Name + " writes a log: " + i + ", on " + DateTime.Now.ToString() + ".\n");
        }// 做一些其它的事件
        for (int i = 0; i < 1000; i++) { }
    }
}
复制代码

代码很简单,相信大家都能看得懂。Logger 大家可以把它看做是一个写日志的组件,先不关心它的具体实现,只要知道它是一个提供了写日志功能的组件就行。

那么,这段代码跟并发和异步有什么关系呢?

我们先用一张图来描述这段代码:

观察上图,3个线程同时调用Logger写日志,对于Logger来说,3个线程同时交给了它任务,这种情况就是并发。对于其中一个线程来说,它在工作过程中,在某个时间请求Logger帮它写日志,同时又继续在自己的其它工作,这种情况就是异步

(经读者反馈,为不“误导”读者(尽管我个人不觉得是误导。之前我的定义和解释不全 面,没有从操作系统和CPU层次去区分这两个概念。我的文章不喜欢搬教科书,只是想用通俗易读的白话让大家理解),为了知识的专业性和严谨,现已把我理解 的对并发和异步的定义删除,感谢园友们的热心讨论)。

 

接下来,我们继续讲几个很有用的有关线程和并发的知识 - 锁、信号机制和线程池。

并发控制 - 锁

CLR 会为每个线程分配自己的内存堆空间,以使他们的本地变量保持分离互不干扰。

线程之间也可以共享通用的数据,比如同一对象的某个属性或全局静态变量。但线程间共享数据是存在安全问题的。举个例子,下面的主线程和新线程共享了变量done,done用来标识某件事已经做过了(告诉其它线程不要再重复做了):

复制代码
class Program {
    static bool done;
    static void Main(string[] args) {

        new Thread(Go).Start(); // 在新的线程上调用Go
        Go(); // 在主线程上调用Go

        Console.ReadKey();
    }

    static void Go() {
        if (!done) {
            Thread.Sleep(500); // 模拟耗时操作
            Console.WriteLine("Done"); 
            done = true;
        }
    }
}
复制代码

输出结果:

输出了两个“Done”,事件被做了两次。由于没有控制好并发,这就出现了线程的安全问题,无法保证数据的状态。

要解决这个问题,就需要用到锁(Lock,也叫排它锁或互斥锁)。使用lock语句,可以保证共享数据只能同时被一个线程访问。lock的数据对象 要求是不能null的引用类型的对象,所以lock的对象需保证不能为空。为此需要创建一个不为空的对象来使用锁,修改一下上面的代码如下:

复制代码
class Program {

    static bool done;
    static object locker = new object(); // !!

    static void Main(string[] args) {

        new Thread(Go).Start(); // 在新的线程上调用Go
        Go(); // 在主线程上调用Go

        Console.ReadKey();
    }

    static void Go() {
        lock (locker) {
            if (!done) {
                Thread.Sleep(500); // Doing something.
                Console.WriteLine("Done");
                done = true;
            }
        }
    }
}
复制代码

再看结果:

使用锁,我们解决了问题。但使用锁也会有另外一个线程安全问题,那就是“死锁”,死锁的概率很小,但也要避免。保证“上锁”这个操作在一个线程上执行是避免死锁的方法之一,这种方法在下文案例中会用到。

这里我们就不去深入研究“死锁”了,感兴趣的朋友可以去查询相关资料。

线程的信号机制

有时候你需要一个线程在接收到某个信号时,才开始执行,否则处于等待状态,这是一种基于信号的事件机制。.NET框架提供一个 ManualResetEvent类来处理这类事件,它的 WaiOne 实例方法可使当前线程一直处于等待状态,直到接收到某个信号。它的Set方法用于打开发送信号。下面是一个信号机制的使用示例:

复制代码
static void Main(string[] args) {

    var signal = new ManualResetEvent(false);

    new Thread(() => {
        Console.WriteLine("Waiting for signal...");
        signal.WaitOne();
        signal.Dispose();
        Console.WriteLine("Got signal!");
    }).Start();
    Thread.Sleep(2000);

    signal.Set();// 打开“信号”

    Console.ReadKey();
}
复制代码

运行结果:

当执行Set方法后,信号保持打开状态,可通过Reset方法将其关闭,若不再需要,通过Dispose将其释放。如果预期的等待时间很短,可以用 ManualResetEventSlim代替ManualResetEvent,前者在等待时间较短时性能更好。信号机制非常有用,后面的日志案例会用 到它。

线程池中的线程

线程池中的线程是由CLR来管理的。在下面两种条件下,线程池能起到最好的效用:

  • 任务运行的时候比较短(<250ms),这样CLR可以充分调配现有的空闲线程来处理该任务;
  • 大量时间处于等待(或阻塞)的任务不去支配线程池的线程。

要使用线程中的线程,主要有下面两种方式:

// 方式1:Task.Run,.NET Framework 4.5 才有
Task.Run (() => Console.WriteLine ("Hello from the thread pool"));

// 方式2:ThreadPool.QueueUserWorkItem
ThreadPool.QueueUserWorkItem (t => Console.WriteLine ("Hello from the thread pool"));

线程池使得线程可以充分有效地被使用,减少了任务启动的延迟。但是不是所有的情况都适合使用线程池中的线程,比如下面要讲的日志案例 - 异步写文件。

这里讲线程池,是为了让大家大致了解什么时候用线程池中的线程,什么时候不用。即,耗时长或有阻塞情况的不用线程池中的线程。

创建不走线程池中的线程,可以直接通过new Thread来创建,也可以通过下面的代码来创建:

Task task = Task.Factory.StartNew (() => ...,TaskCreationOptions.LongRunning);// 注意必须带TaskCreationOptions.LongRunning参数

这里用到了Task,大家不用关心它,后续博文会详细讲。

关于线程的知识很多,这里不再深入了,因为这些已经足够让我们应付Web开发了。

案例:支持并发的异步日志组件

上文的“并发和异步的区别”的代码中我们用到了一个Logger类,现在我们就来做一个这样的Logger。

基于上面的知识,我们可以实现应用程序的并发写日志日志功能。在应用程序中,写日志是常见的功能,简单分析一下该功能的需求:

  1. 在后台异步执行,和其它线程互不影响。
    根据上文线程池的两个最优使用条件,由写日志线程会长时间处于阻塞(或运行等待)状态,所以它不适合使用线程池。即不能使用Task.Run,而最好使用new Thread。

  2. 支持并发,即多个任务(分布在不同线程上)可同时调用写日志功能,但需保证线程安全。
    支持并发,必然要用到锁,但要完全保证线程安全,那就要想办法避免“死锁”。只要我们把“上锁”的操作始终由同一个线程来做即可避免“死锁”问题,但这样的话,并发请求的任务只能放在队列中由该线程依次执行(因为是后台执行,无需即时响应用户,所以可以这么做)。

  3. 单个实例,单个线程。
    任何地方调用写日志功能都调用的是同一个Logger实例(显然不能每次写日志都新建一个实例),即需使用单例模式。不管有多少任务调用写日志功能,都必须始终使用同一个线程来处理这些写日志操作,以保证不占用过多的线程资源和避免新建线程带来的延迟。

运用上面的知识,我们来写一个这样的类。简单理一下思路:

  1. 需要一个用来存放写日志任务的队列。
  2. 需要有一个信号机制来标识是否有新的任务要执行。
  3. 当有新的写日志任务时,将该任务加入到队列中,并发出信号。
  4. 用一个方法来处理队列中的任务,当接收新任务信号时,就依次调用队列中的任务。

开发一个功能前需要有个简单的思路,保证心里面有底。具体开发的时候会发现问题,然后再去补充扩展和完善等。刚开始很难想得太周全,先有个简单的思路,然后代码写起来!

下面是这样一个Logger类初步实现:

复制代码
public class Logger {

    // 用于存放写日志任务的队列
    private Queue<Action> _queue;

    // 用于写日志的线程
    private Thread _loggingThread;

    // 用于通知是否有新日志要写的“信号器”
    private ManualResetEvent _hasNew;

    // 构造函数,初始化。
    private Logger() {
        _queue = new Queue<Action>();
        _hasNew = new ManualResetEvent(false);

        _loggingThread = new Thread(Process);
        _loggingThread.IsBackground = true;
        _loggingThread.Start();
    }

    // 使用单例模式,保持一个Logger对象
    private static readonly Logger _logger = new Logger();
    private static Logger GetInstance() {
        /* 不安全代码
        lock (locker) {
            if (_logger == null) {
                _logger = new Logger();
            }
        }*/
        return _logger;
    }

    // 处理队列中的任务
    private void Process() {
        while (true) {
            // 等待接收信号,阻塞线程。
            _hasNew.WaitOne();

            // 接收到信号后,重置“信号器”,信号关闭。
            _hasNew.Reset(); 

            // 由于队列中的任务可能在极速地增加,这里等待是为了一次能处理更多的任务,减少对队列的频繁“进出”操作。
            Thread.Sleep(100);

            // 开始执行队列中的任务。
            // 由于执行过程中还可能会有新的任务,所以不能直接对原来的 _queue 进行操作,
            // 先将_queue中的任务复制一份后将其清空,然后对这份拷贝进行操作。

            Queue<Action> queueCopy;
            lock (_queue) {
                queueCopy = new Queue<Action>(_queue);
                _queue.Clear();
            }

            foreach (var action in queueCopy) {
                action();
            }
        }
    }

    private void WriteLog(string content) {
        lock (_queue) { // todo: 这里存在线程安全问题,可能会发生阻塞。
            // 将任务加到队列
            _queue.Enqueue(() => File.AppendAllText("log.txt", content));
        }

        // 打开“信号”
        _hasNew.Set();
    }

    // 公开一个Write方法供外部调用
    public static void Write(string content) {
        // WriteLog 方法只是向队列中添加任务,执行时间极短,所以使用Task.Run。
        Task.Run(() => GetInstance().WriteLog(content));
    }
}
复制代码

类写好了,用上文“并发和异步的区别”中的代码测试一下这个Logger类,在我的电脑上运行的一次结果:

 共3000条日志,结果没有问题。

上面的Logger类注释写得很详细,我就不再解析了。

通过这个示例,目的是让大家掌握线程和并发在开发中的基本应用和要注意的问题。

遗憾的是这个Logger类并不完美,而且存在线程安全问题(代码中用红色字体标出),虽然实际环境概率很小。可能上面代码多次运行都很难看到有异常发生(我多次运行未发生异常),但同时再添加几个线程可能就会有问题了。

那么,如何解决这个线程安全问题呢?

 

引用地址:http://www.cnblogs.com/kesimin/p/5085460.html

转载于:https://www.cnblogs.com/Arlar/p/7967258.html

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
1.几种同步方法的区别 lock和Monitor是.NET用一个特殊结构实现的,Monitor对象是完全托管的、完全可移植的,并且在操作系统资源要求方 面可能更为有效,同步速度较快,但不能跨进程同步。lock(Monitor.Enter和Monitor.Exit方法的封装),主要作用是定临界区,使临 界区代码只能被获得线程执行。Monitor.Wait和Monitor.Pulse用于线程同步,类似信号操作,个人感觉使用比较复杂,容易造成死 。 互斥体Mutex和事件对象EventWaitHandler属于内核对象,利用内核对象进行线程同步,线程必须要在用户模式和内核模 式间切换,所以一般效率很低,但利用互斥对象和事件对象这样的内核对象,可以在多个进程中的各个线程间进行同步。 互斥体Mutex类似于一个接力棒,拿到接力棒的线程才可以开始跑,当然接力棒一次只属于一个线程(Thread Affinity),如果这个线程不释放接力棒(Mutex.ReleaseMutex),那么没办法,其他所有需要接力棒运行的线程都知道能等着看热 闹。 EventWaitHandle 类允许线程通过发信号互相通信。 通常,一个或多个线程在 EventWaitHandle 上阻止,直到一个未阻止的线程调用 Set 方法,以释放一个或多个被阻止的线程。 2.什么时候需要定 首先要理解定是解决竞争条件的,也就是多个线程同时访问某个资源,造成意想不到的结果。比如,最简单的情况是,一个计数器,两个线程 同时加一,后果就是损失了一个计数,但相当频繁的定又可能带来性能上的消耗,还有最可怕的情况死。那么什么情况下我们需要使用,什么情况下不需要 呢? 1)只有共享资源才需要定 只有可以被多线程访问的共享资源才需要考虑定,比如静态变量,再比如某些缓存中的值,而属于线程内部的变量不需要定。 2)多使用lock,少用Mutex 如果你一定要使用定,请尽量不要使用内核模块的定机制,比如.NET的Mutex,Semaphore,AutoResetEvent和 ManuResetEvent,使用这样的机制涉及到了系统在用户模式和内核模式间的切换,性能差很多,但是他们的优点是可以跨进程同步线程,所以应该清 楚的了解到他们的不同和适用范围。 3)了解你的程序是怎么运行的 实际上在web开发中大多数逻辑都是在单个线程中展开的,一个请求都会在一个单独的线程中处理,其中的大部分变量都是属于这个线程的,根本没有必要考虑 定,当然对于ASP.NET中的Application对象中的数据,我们就要考虑加了。 4)把定交给数据库 数 据库除了存储数据之外,还有一个重要的用途就是同步,数据库本身用了一套复杂的机制来保证数据的可靠和一致性,这就为我们节省了很多的精力。保证了数据源 头上的同步,我们多数的精力就可以集中在缓存等其他一些资源的同步访问上了。通常,只有涉及到多个线程修改数据库中同一条记录时,我们才考虑加。 5)业务逻辑对事务和线程安全的要求 这 条是最根本的东西,开发完全线程安全的程序是件很费时费力的事情,在电子商务等涉及金融系统的案例中,许多逻辑都必须严格的线程安全,所以我们不得不牺牲 一些性能,和很多的开发时间来做这方面的工作。而一般的应用中,许多情况下虽然程序有竞争的危险,我们还是可以不使用定,比如有的时候计数器少一多一, 对结果无伤大雅的情况下,我们就可以不用去管它。 3.InterLocked类 Interlocked 类提供了同步对多个线程共享的变量的访问的方法。如果该变量位于共享内存中,则不同进程的线程就可以使用该机制。互操作是原子的,即整个操作是不能由相 同变量上的另一个互操作所中断的单元。这在抢先多线程操作系统中是很重要的,在这样的操作系统中,线程可以在从某个内存地址加载值之后但是在有机会更改 和存储该值之前被挂起。
C# 中,线程并发线程同步是处理多线程编程中重要的概念。 线程并发指的是多个线程同时执行,这可能导致资源竞争和不确定的结果。为了避免这种情况,我们可以使用线程同步机制来确保线程之间的协调和有序执行。 C# 提供了多种线程同步的机制,下面是一些常用的方法: 1. 互斥(Mutex):互斥是一种排他,只允许一个线程访问被保护的资源。可以使用 `Mutex` 类来创建和管理互斥。 2. 信号量(Semaphore):信号量是一种计数器,用于控制同时访问某个资源的线程数。可以使用 `Semaphore` 类来创建和管理信号量。 3. 自旋(SpinLock):自旋是一种忙等待线程会一直尝试获取,直到成功为止。可以使用 `SpinLock` 结构来创建和管理自旋。 4. 互斥量(Mutex):互斥量是一种特殊的信号量,只能被一个线程持有。可以使用 `Mutex` 类来创建和管理互斥量。 5. 事件(Event):事件是一种同步机制,在多个线程之间发送信号进行通信。可以使用 `ManualResetEvent` 或 `AutoResetEvent` 类来创建和管理事件。 除了上述方法外,还有一些其他的线程同步机制,如读写(ReaderWriterLock)、条件变量(Monitor)等。选择适合场景的线程同步机制很重要,以确保线程安全和性能。 需要注意的是,线程并发线程同步是一个复杂的主题,需要深入学习和实践才能掌握。在编写多线程代码时,建议仔细考虑并发问题,并使用适当的线程同步机制来确保代码的正确性和可靠性。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值