C#线程

2022/9/14 1:18:54

本文主要是介绍C#线程,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

线程是一个可以独立执行的执行路径。
每一个线程都运行在一个操作系统进程中。这个进程提供了程序执行的独立环境。
在单线程程序中,进程中只有一个线程运行,因此线程可以独立使用进程环境。而在多线程程序中,一个进程中会运行多个线程。它们共享同一个执行环境(特别是内存)。这在一定程度上说明了多线程的作用。例如,可以使用一个线程在后台获得数据,同时使用另一个线程显示所获得的数据。而这些数据就是所谓的共享状态。

1.创建线程

客户端程序(控制台、WPF、WinForms)在启动时都会从操作系统自动创建一个主线程。除非手动创建多个线程,否则该应用程序就是一个单线程的应用程序。

要创建并启动一个线程,需要首先实例化Thread对象并调用Start方法。Thread的最简单的构造器接收一个ThreadStart委托:一个无参数的方法,表示执行的起始位置。例如:

static void Main()
{
    Thread t = new Thread (WriteY);
    t.Start();

    for (int i = 0; i < 1000; i++) Console.Write ("x");
}

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

主线程会创建一个新的线程t,而新的线程会执行方法重复地输出字符y。同时,主线程也会重复地输出字符x。

在单核计算机上,操作系统会为每一个线程划分时间片(Windows系统的典型值为20毫秒)来模拟并发执行。因此上述代码会出现连续的x和y。而在多核心的计算机上,两个线程可以并行执行(会和计算机上其他执行的进程进行竞争),因此虽然我们还是会得到连续的x和y,但这却是由于Console处理并发请求的机制导致的。!

image

线程一旦启动,其IsAlive属性就会返回true,直至线程停止。当Thread的构造函数接收的委托执行完毕后,线程就会停止。线程停止后就无法再启动了。
每一个线程都有一个Name属性用于调试用途。线程的名称只能设置一次,试图修改线程的名称会抛出异常。
静态属性Thread.CurrentThread将返回当前正在执行的线程:

Console.WriteLine(Thread.CurrentThread.Name);

2.汇合与休眠

调用Thread的Join方法可以等待其他线程结束。

static void Main()
{
    Thread t = new Thread(Go);
    t.Start();
    t.Join();
    Console.WriteLine("线程t结束");
    Console.ReadKey();
}

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

这段代码会打印1 000次“y”字符,然后打印“线程t结束”。调用Join时可以指定一个超时时间(以毫秒为单位或者用TimeSpan定义)。如果线程在指定时间内正常结束,则返回true;如果超时则返回false。

Thread.Sleep方法将当前线程的执行暂停指定的时间。

Thread.Sleep(TimeSpan.FromHours(1));
Thread.Sleep(500);

Thread.Sleep(0)将会导致线程立即放弃自己的时间片,自觉地将CPU交于其他的线程。
Thread.Yield()执行相同的操作,但是它只会将资源交给同一个处理器上运行的线程。
在等待线程Sleep或者Join的过程中,线程是阻塞的。

3.阻塞

线程由于特定原因暂停执行,那么它就是阻塞的。例如,调用Sleep休眠或者Join等待其他线程执行结束。阻塞的线程会立刻交出它的处理器时间片,并从此开始不再消耗处理器时间,直至阻塞条件结束。可以使用ThreadState属性测试线程的阻塞状态:

bool blocked = (someThread.ThreadState & ThreadState.WaitSleepJoin) != 0;

ThreadState是一个标志枚举类型,它由“三层”二进制位组成。然而,其中的大多数值都是冗余、无用或者废弃的。ThreadState包括这几个有用的枚举:Unstarted、Running、WaitSleepJoin、Stopped。

当线程被阻塞或者解除阻塞时,操作系统就会进行一次上下文切换。这会导致细小的开销,一般在1~2微秒。

I/O密集和计算密集

如果一个操作的绝大部分时间都在等待事件的发生,则称为I/O密集,例如下载网页或者调用Console.ReadLine。I/O密集操作一般都会涉及输入或者输出,但是这并非硬性要求。例如Thread.Sleep也是一种I/O密集的操作。相反,如果操作的大部分时间都用于执行大量的CPU操作,则称为计算密集

阻塞与自旋

I/O密集操作主要表现为以下两种形式:

  • 要么在当前线程同步进行等待,直至操作完成(例如Console.ReadLine、Thread.Sleep以及Thread.Join);
  • 要么异步进行操作,在操作完成的时候或者之后某个时刻触发回调函数。

同步的I/O密集操作大部分时间都花费在阻塞线程上,但是也可能在一个定期循环中自旋:

while(DateTime.Now < nextStartTime)
    Thread.Sleep(100);

4.本地状态与共享状态

CLR为每一个线程分配了独立的内存栈,从而保证了局部变量的隔离。下面的示例定义了一个拥有局部变量的方法,并同时在主线程和新创建的线程中调用该方法:

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

static void Go()
{
    for (int cycles = 0; cycles < 5; cycles++) 
        Console.Write('?');
}

由于每一个线程的内存栈上都会有一个独立的cycles变量副本,因此我们可以预测,程序的输出将是10个问号。

如果不同的线程拥有同一个对象的引用,则这些线程之间就共享了数据:

void Main()
{
    ThreadTest.Main();
}
class ThreadTest
{
    bool _done;

    public static void Main()
    {
        ThreadTest tt = new ThreadTest();  
        new Thread (tt.Go).Start();
        tt.Go();
    }

    void Go()   // 这是一个实例方法
    {
        if (!_done) 
        { 
            _done = true; 
            Console.WriteLine ("Done"); 
        }
    }
}

由于两个线程均在同一个ThreadTest实例上调用了Go()方法,因此它们共享_done字段。因此,“Done”只会打印一次,而非两次。

编译器会将Lambda表达式捕获的局部变量或匿名委托转换为字段,因此它们也可以被共享:

static void Main()
{
    bool done = false;
    ThreadStart action = () =>
    {
        if (!done)
        {
            done = true;
            Console.WriteLine("Done");
        }
    };
    new Thread(action).Start();
    action();
}

以上例子均演示了另一个重要的概念:线程的安全性(或者缺少线程安全性)。
它们的输出是不确定的:有可能(虽然概率很小)会打印两次“Done”,然而,如果我们对调Go方法中的语句,则打印两个“Done”的可能性将大大增加:

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

上述语句的问题是,当一个线程在判断if语句的时候,另一个线程有可能在done设置为true前就已经开始执行WriteLine语句了。

5.锁与线程安全

在读写共享字段时首先获得一个排他锁可以修正之前示例的问题。使用C#的lock语句就可以实现这个目标:

class ThreadSafe 
{
    static bool _done;
    static readonly object _locker = new object();

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

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

当两个线程同时竞争一个锁时(它可以是任意引用类型的对象,这里是_locker),一个线程会进行等待(阻塞),直到锁被释放。这样就保证了一次只有一个线程能够进入代码块,因此“Done”只会打印一次。在不确定的多线程上下文,采用这种方式进行保护的代码称为线程安全的代码。

6.向线程传递数据

有时我们需要给线程的启动方法传递参数。最简单的方案是使用Lambda表达式,并在其中使用指定的参数调用相应的方法:

static void Main()
{
    Thread t = new Thread(() => Print("参数"));
    t.Start();
}
static void Print(string message)
{
    Console.WriteLine(message);
}

这种方法可以向方法传递任意数量的参数。甚至可以将整个实现过程封装在一个多语句的Lambda表达式:

new Thread(() =>
{
    Console.WriteLine("参数");
});

另一种是向Thread的Start方法传递一个参数:

static void Main()
{
    Thread t = new Thread(Print);
    t.Start("参数");
}
static void Print(object message)
{
    Console.WriteLine(message.ToString());
}

由于Thread对象的重载构造器可以接受以下两种委托的任意一种,因此以上的代码是奏效的:

public delegate void ThreadStart();
public delegate void ParameterizedThreadStart(object? obj);

7.异常处理

线程执行和线程创建时所处的try/catch/finally语句块无关。假设有如下程序:

static void Main()
{
    try
    {
        new Thread (Go).Start();
    }
    catch (Exception ex)
    {
        Console.WriteLine ("Exception!");
    }
}

static void Go() { throw null; } 

本例中的try/catch语句是无效的。新创建的线程会被未处理的NullReferenceException异常影响。如果将每一个线程看作独立的执行路径,那么就可以理解上述行为了。
解决方法是将异常处理器移到Go方法内:

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

static void Go()
{
    try
    {
        throw null;   
    }
    catch (Exception ex)
    {
        Console.WriteLine ("Exception!");
    }
}

8.前台线程与后台线程

一般情况下,显式创建的线程称为前台线程。只要有一个前台线程还在运行,应用程序就仍然保持运行状态。而后台线程则不然。当所有前台线程结束时,应用程序就会停止,且所有运行的后台线程也会随之终止。

可以使用线程的IsBackground属性来查询或修改线程的前后台状态:

static void Main(string[] args)
{
    Thread worker = new Thread(() => Console.ReadLine());
    if (args.Length > 0)
    {
        worker.IsBackground = true;
    }
    worker.Start();
}

如果应用程序调用时不带有任何参数,则工作线程会处于前台状态,并在ReadLine语句处等待用户的输入。主线程结束时,由于前台线程仍然在运行,因此应用程序会继续保持运行状态。如果应用程序启动时带有参数,则工作线程就会设置为后台状态,而应用程序也将在主线程结束时退出,从而终止ReadLine的执行。

9.线程的优先级

线程的Priority属性可以决定相对于其他线程,当前线程在操作系统中分配的执行时间的长短。具体的优先级包括:

public enum ThreadPriority
{
    Lowest = 0,
    BelowNormal = 1,
    Normal = 2,
    AboveNormal = 3,
    Highest = 4
}

如果同时激活多个线程,优先级就变得很重要。提升一个线程的优先级需要慎重,因为其他线程的执行时间就可能减少而处于饥饿状态。如果你希望一个线程比其他进程中的线程有更高的优先级,那么还必须使用System.Diagnostics命名空间下的Process类提高进程本身的优先级:

using Process p = Process.GetCurrentProcess();
p.PriorityClass = ProcessPriorityClass.High;

这种方法非常适用于一些工作量比较少,但是要求较低延迟(能够快速响应)的非UI进程中。在计算密集,特别是带有用户界面的应用程序中,提高进程的优先级可能会挤占其他进程的执行时间,从而影响整个计算机的运行速度。

10.信号发送

有时一个线程需要等待来自其他线程的通知,即所谓的信号发送。
最简单的信号发送结构是ManualResetEvent。调用ManualResetEvent的WaitOne方法可以阻塞当前线程,直到其他线程调用Set“打开”了信号。

以下的示例启动了一个线程,并等待ManualResetEvent。它会阻塞两秒钟,直至主线程发送信号为止:

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();

Set调用后,信号发送结构仍然会保持“打开”状态,可以调用Reset方法再次将其“关闭”。

11.线程池

每当启动一个线程时,都需要一定的时间(几百微秒)来创建新的局部变量栈。而线程池通过预先创建一个可回收线程的池子来降低这个开销。线程池对开发高性能的并行程序与控制细粒度的并发都是非常必要的。它可以支持运行一些短暂的操作而不会受到线程启动开销的影响。

使用线程池中的线程时还需要考虑以下问题:

  • 线程池中线程的Name属性是无法进行设置的,因此会增加代码调试的难度。
  • 线程池中的线程都是后台线程。
  • 阻塞线程池中的线程将影响性能

我们可以任意设置线程池中线程的优先级,而当将线程归还线程池时其优先级会恢复为普通级别。
Thread.CurrentThread.IsThreadPoolThread属性可用于确认当前运行的线程是否是一个线程池线程。

在线程池上运行代码的最简单方式是调用Task.Run

Task.Run(()=>Console.WriteLine("线程池"));

.NET Framework 4.0之前没有Task类,因此可以调用ThreadPool.QueueUserWorkItem

ThreadPool.QueueUserWorkItem(notUsed => Console.WriteLine("线程池"));


这篇关于C#线程的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程