C#多线程

目录

1 简介及概念

1.1 Join 和 Sleep

1.2 线程是如何工作的

1.3 线程 vs 进程

1.4线程的使用与误用

维持用户界面的响应

有效利用 CPU

并行计算

推测执行(speculative execution)

允许同时处理请求


1 简介及概念

·C# 支持通过多线程并行执行代码,线程有其独立的执行路径,能够与其它线程同时执行。

·一个 C# 客户端程序(Console 命令行、WPF 以及 Windows Forms)开始于一个单线程,这个线程(也称为“主线程”)是由 CLR 和操作系统自动创建的,并且也可以再创建其它线程。以下是一个简单的使用多线程的例子:

所有示例都假定已经引用了以下命名空间:

>using System;
>using System.Threading;

class ThreadTest
{
    static void Main()
    {
        Thread t = new Thread(WriteY);  // 创建新线程
        t.Start();                       // 启动新线程,执行WriteY()

        // 同时,在主线程做其它事情
        for (int i = 0; i < 1000; i++) Console.Write("x");
    }

    static void WriteY()
    {
        for (int i = 0; i < 1000; i++) Console.Write("y");
    }
}

输出结果:

xxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxx
...

主线程创建了一个新线程t来不断打印字母 “ y “,与此同时,主线程在不停打印字母 “ x “。

线程一旦启动,线程的IsAlive属性值就会为true,直到线程结束。当传递给Thread的构造方法的委托执行完成时,线程就会结束。一旦结束,该线程不能再重新启动。

CLR 为每个线程分配各自独立的栈空间,因此局部变量是独立的。在下面的例子中,我们定义一个拥有局部变量的方法,然后在主线程和新创建的线程中同时执行该方法。

static void Main()
{
    new Thread(Go).Start();      // 在新线程执行Go()
    Go();                         // 在主线程执行Go()
}

static void Go()
{
    // 定义和使用局部变量 - 'cycles'
    for (int cycles = 0; cycles < 5; cycles++) Console.Write('?');
}

输出结果:??????????

变量cycles的副本是分别在各自的栈中创建的,因此才会输出 10 个问号。

线程可以通过对同一对象的引用来共享数据。例如:

class ThreadTest
{
    bool done;

    static void Main()
    {
        ThreadTest tt = new ThreadTest();   // 创建一个公共的实例
        new Thread(tt.Go).Start();
        tt.Go();
    }

    // 注意: Go现在是一个实例方法
    void Go()
    {
        if (!done) { done = true; Console.WriteLine("Done"); }
    }
}

由于两个线程是调用了同一个的ThreadTest实例上的Go(),它们共享了done字段,因此输出结果是一次 “ Done “,而不是两次。

输出结果:Done

静态字段提供了另一种在线程间共享数据的方式,以下是一个静态的done字段的例子:

class ThreadTest
{
    static bool done;    // 静态字段在所有线程中共享

    static void Main()
    {
        new Thread(Go).Start();
        Go();
    }

    static void Go()
    {
        if (!done) { done = true; Console.WriteLine("Done"); }
    }
}

以上两个例子引出了一个关键概念线程安全(thread safety)。上述两个例子的输出实际上是不确定的:” Done “ 有可能会被打印两次。如果在Go
方法里调换指令的顺序,” Done “ 被打印两次的几率会大幅提高:

static void Go()
{
    if (!done) { Console.WriteLine("Done"); done = true; }
}

输出结果:

Done
Done(很可能!)

这个问题是因为一个线程对if中的语句估值的时候,另一个线程正在执行WriteLine语句,这时done还没有被设置为true。

修复这个问题需要在读写公共字段时,获得一个排它锁(互斥锁,exclusive lock )。C# 提供了lock来达到这个目的:

class ThreadSafe
{
    static bool done;
    static readonly object locker = new object();

    static void Main()
    {
        new Thread(Go).Start();
        Go();
    }

    static void Go()
    {
        lock (locker)
        {
            if (!done) { Console.WriteLine("Done"); done = true; }
        }
    }
}

当两个线程同时争夺一个锁的时候(例子中的locker),一个线程等待,或者说阻塞,直到锁变为可用。这样就确保了在同一时刻只有一个线程能进入临界区(critical section,不允许并发执行的代码),所以 “ Done “ 只被打印了一次。像这种用来避免在多线程下的不确定性的方式被称为线程安全(thread-safe)。

在线程间共享数据是造成多线程复杂、难以定位的错误的主要原因。尽管这通常是必须的,但应该尽可能保持简单。

一个线程被阻塞时,不会消耗 CPU 资源。


1.1 Join 和 Sleep

可以通过调用Join方法来等待另一个线程结束,例如:

static void Main()
{
    Thread t = new Thread(Go);
    t.Start();
    t.Join();
    Console.WriteLine("Thread t has ended!");
}

static void Go()
{
    for (int i = 0; i < 1000; i++) Console.Write("y");
}

输出 “ y “ 1,000 次之后,紧接着会输出 “ Thread t has ended! “。当调用Join时可以使用一个超时参数,以毫秒或是TimeSpan形式。如果线程正常结束则返回true,如果超时则返回false。

Thread.Sleep会将当前的线程阻塞一段时间:

Thread.Sleep (TimeSpan.FromHours (1));  // 阻塞 1小时
Thread.Sleep (500);                     // 阻塞 500 毫秒

当使用Sleep或Join等待时,线程是阻塞(blocked)状态,因此不会消耗 CPU 资源。

Thread.Sleep(0)会立即释放当前的时间片,将 CPU 资源出让给其它线程。Framework 4.0 新的Thread.Yield()方法与其相同,除了它只会出让给运行在相同处理器核心上的其它线程。

Sleep(0)和Yield在调整代码性能时偶尔有用,它也是一个很好的诊断工具,可以用于找出线程安全(thread safety)的问题。如果在你代码的任意位置插入Thread.Yield()会影响到程序,基本可以确定存在 bug。

1.2 线程是如何工作的

线程在内部由一个线程调度器(thread scheduler)管理,一般 CLR 会把这个任务交给操作系统完成。线程调度器确保所有活动的线程能够分配到适当的执行时间,并且保证那些处于等待或阻塞状态(例如,等待排它锁或者用户输入)的线程不消耗CPU时间。

在单核计算机上,线程调度器会进行时间切片(time-slicing),快速的在活动线程中切换执行。在 Windows 操作系统上,一个时间片通常在十几毫秒(译者注:默认 15.625ms),远大于 CPU 在线程间进行上下文切换的开销(通常在几微秒区间)。

在多核计算机上,多线程的实现是混合了时间切片和真实的并发,不同的线程同时运行在不同的 CPU 核心上。几乎可以肯定仍然会使用到时间切片,因为操作系统除了要调度其它的应用,还需要调度自身的线程。

线程的执行由于外部因素(比如时间切片)被中断称为被抢占(preempted)。在大多数情况下,线程无法控制其在何时及在什么代码处被抢占。

1.3 线程 vs 进程

好比多个进程并行在计算机上执行,多个线程是在一个进程中并行执行。进程是完全隔离的,而线程是在一定程度上隔离。一般的,线程与运行在相同程序中的其它线程共享堆内存。这就是线程为何有用的部分原因,一个线程可以在后台获取数据,而另一个线程可以同时显示已获取到的数据。

1.4线程的使用与误用

多线程有许多用处,下面是通常的应用场景:

维持用户界面的响应

使用工作线程并行运行时间消耗大的任务,这样主UI线程就仍然可以响应键盘、鼠标的事件。

有效利用 CPU

多线程在一个线程等待其它计算机或硬件设备响应时非常有用。当一个线程在执行任务时被阻塞,其它线程就可以利用这个空闲出来的CPU核心。

并行计算

在多核心或多处理器的计算机上,计算密集型的代码如果通过分治策略(divide-and-conquer,见第 5 部分)将工作量分摊到多个线程,就可以提高计算速度。

推测执行(speculative execution)

在多核心的计算机上,有时可以通过推测之后需要被执行的工作,提前执行它们来提高性能。LINQPad就使用了这个技术来加速新查询的创建。另一种方式就是可以多线程并行运行解决相同问题的不同算法,因为预先不知道哪个算法更好,这样做就可以尽早获得结果。

允许同时处理请求

在服务端,客户端请求可能同时到达,因此需要并行处理(如果你使用 ASP.NET、WCF、Web Services 或者 Remoting,.NET Framework 会自动创建线程)。这在客户端同样有用,例如处理 P2P 网络连接,或是处理来自用户的多个请求。

如果使用了 ASP.NET 和 WCF 之类的技术,可能不会注意到多线程被使用,除非是访问共享数据时(比如通过静态字段共享数据)。如果没有正确的加锁,就可能产生线程安全问题。
多线程同样也会带来缺点,最大的问题是它提高了程序的复杂度。使用多个线程本身并不复杂,复杂的是线程间的交互(一般是通过共享数据)。无论线程间的交互是否有意为之,都会带来较长的开发周期,以及带来间歇的、难以重现的 bug。因此,最好保证线程间的交互尽量少,并坚持简单和已被证明的多线程交互设计。这篇文章主要就是关于如何处理这种复杂的问题,如果能够移除线程间交互,那会轻松许多。

一个好的策略是把多线程逻辑使用可重用的类封装,以便于独立的检验和测试。.NET Framework 提供了许多高层的线程构造,之后会讲到。

当频繁地调度和切换线程时(并且如果活动线程数量大于 CPU 核心数),多线程会增加资源和 CPU 的开销,线程的创建和销毁也会增加开销。多线程并不总是能提升程序的运行速度,如果使用不当,反而可能降低速度。 例如,当需要进行大量的磁盘 I/O 时,几个工作线程顺序执行可能会比 10 个线程同时执行要快。(在使用Wait和Pulse进行同步中,将会描述如何实现 生产者 / 消费者队列,它提供了上述功能。)


 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 10
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Upaaui

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值