C#中的并发与异步 ---- Thread

2,170 阅读4分钟

线程

Thread类在System.Threading程序集中,在初步学习线程时,线程之间的状态转换以及Thread提供的以下几个方法需要了解:

  • 线程状态转移图:


Start:操作系统将当前实例的状态更改为 Running。(运行态)
Join:等待由该实例表示的线程终止。
Sleep:将当前线程的执行暂停指定的时间。
Suspend:挂起线程,或者如果线程已挂起,则不起作用。
Resume:继续已挂起的线程。

创建线程

使用构造器来创建线程,构造器接受一个委托表示线程要执行的动作,由委托的具体实现函数有没有参数分别调用两种构造函数:

  • 无参数的构造器
//创建并启动线程
Thread t1 = new Thread(MyFuction);
t1.Start();

//MyFuction函数体
static void MyFuction(Object a)
{
    for(int i = 0;i < 100; i++)
    {
        Console.Write("2");
    }
}
  • 有参数的构造器
static void Main(string[] args)
{
	//创建线程并传入参数
    Thread t = new Thread(MyFuction);
    t.Start(10);
    for(int i = 0; i < 100; i++)
    {
        Console.Write("1");
    }
}
//具体函数的参数声明
static void MyFuction(Object a)
{
    for(int i = 0;i < 100; i++)
    {
        Console.Write("2");
    }
}

注意:创建线程传入的参数类型只能声明为Object,并且是在Start方法中将参数传入。 运行结果如图:

向线程传递数据

除了上述的通过Object对象来封装参数并传递给Thread对象的方式,使用Lambda表达式更为简洁和有效:

static void Main()
{
    Thread t = new Thread( () => {
    	MyFuction(prama1,prama2);
    });
}
static void MyFuction(object prama1,object prama2)
{
    //业务逻辑
    ....
}

对比两种方式,使用Lambda表达式实际上实在方法中再次调用其他有参数的方法来传值,而使用构造器的方式需要将所有参数封装进一个object。

注意

在线程开始后不要随意修改参数变量的值,举例:

for(int i = 0;i < 10;i++)
{
    new Thread(() => {
    	Console.Write(i)
    }).Start();
}

以上程序输出的结果可能不是想象中的0123456789 而可能是0444555999这样不确定的结果。
原因在于创建线时传入的参数i,在整个循环周期中都在同一个内存地址,创建的十个线程在执行具体的打印任务时访问的也都是同一个地址。

本地状态和共享状态

本地状态

CLR为每个线程分配了独立的内存栈来保证局部变量的隔离。

static void Main()
{
    new Thread(MyFuction).Start();
    MyFuction();
}
static void MyFuction()
{
	for(int i = 0;i < 10;i++)
	Console.Write("1");
}

以上代码会输出20个1,因为变量i在两个线程中是隔离的。

共享状态

导致共享状态的原因是多个线程使用了同一个引用,造成这样的结果有以下几种方式:

  • Lambda表达式将捕获到的变量转换为字段
static void Main()
{
    bool _done = false;
    ThreadStart myFuction = () => {
        if(!_done){
            _done = true;
            Console.Write("Done");
        }
    };
    new Thread(myFuction).Start();
    myFuction();
}

Done只会被打印一次而不是两次,因为Lambda表达式将_done转换为了字段,造成两个线程使用了同一个_done

  • 多个线程使用了同一个实例对象的引用,那么对象的数据就会被多个线程共享
class ThreadTest
{
    bool _done;
    static void Main(){
        //创建了一个共享的实例
        Thread t = new ThreadTest();

        new Thread(myFuction).Start();
        myFuction();
    }
    
}
  • 多个线程共享静态字段
class ThreadTest
{
    //被共享的静态字段
    static bool _done;
    static void Main(){
        new Thread(myFuction).Start();
        myFuction();
    }
    
}

共享数据会造成不安全的操作,多个线程共享变量时,上述的几种方式有概率会打印两次Done

锁与线程安全

在多个线程中共享数据的基本处理方法:C#的lock语句

class ThreadTest
{
    static bool _done;
    //排他锁
    static readonly object _locker = new object();

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

    static void myFuction(){
        //排他锁
        lock (_locker)
        {
            if(!_done){
            Console.Write("Done");
            _done = true;
            }
        }
    }
}

两个线程竞争锁资源时,一个线程会等待另一个线程释放资源,保证数据安全。

线程中的异常处理

线程执行与创建线程的异常处理流程无关,举例:

static void Main()
{
    try{
        new Thread(myFuction).Start();
    }catch{
        Console.Write("捕获异常");
    }
}
static void myFuction(){
    //抛出一个空引用异常
    throw null;
}

上面的例子中永远都捕获不到异常,新创建的线程依旧会被空引用异常影响。 正常的方式应该时将异常处理的流程放进myFuction方法内。

前台程序与后台程序

Thread类中的IsBackground属性标记线程是否是后台程序。

  • 显示创建的线程为前台线程,即IsBackground属性为rue;
  • 只要有一个前台线程还在运行,应用程序就仍然保持运行状态。
  • 当所有前台线程结束时,应用程序就会停止,且所有运行的后台线程也会随之终止。 举例:
static void Main(string[] args)
{
    Thread thread = new Thread(() =>
    {
        Console.ReadLine();
    });
    if (args.Length > 0)
    {
        thread.IsBackground = false;
    }
    thread.Start();
}

从命令行运行上述程序,不带参数时(dotnet run)线程处于前台状态,会一直等待用户输入而不会结束;
如果应用程序启动时带有参数,则工作线程就会设置为后台状态,而应用程序也将在主线程结束时退出,从而终止ReadLine的执行。