C# 系列 -- 多线程

993 阅读6分钟

多线程

线程基础

了解过计算机的人可能知道 程序最小执行单元是线程,最小资源分配单位是进程进程里必然至少有一个线程,而一个程序也必然至少有一个进程。这里不过多的介绍进程和线程的区别于关系,只需要记着线程是程序最小执行单元,我们在开发中最常用的也是线程。

在很多不太严谨的编程教程中,都会把多线程和并行化作等号。但是这里有一个很微妙的区别,对于单核CPU来说,多进程和多线程一样,都不会产生并行的效果;对于多核CPU而言,多进程必然是并行的,但是多线程则不一定并行。所以C#中,线程更多的用作异步处理上,而不是并行计算上。

在C#程序中,需要引用System.Threading。C#的入门级线程操作只需要知道Thread类、一个带参数的无返回值方法和一个不带参数的无返回值方法,这三个要点就可以了。

线程的状态

一般情况线程分为五个阶段,也就是五种状态:分别是【准备、就绪、运行、阻塞、死亡】。当然在不同的地方,状态可能会细分为更多的级别,这里只做初步的介绍。状态之间的切换如下:

image-20200423102030652

线程的状态之间切换顺序有着严格的限制,而且只能从就绪态由CPU切换到运行态,运行态无法从其他状态切换过去,而且这一步的切换开发者不能控制

线程在程序中的操作

1、创建线程

通过声明并实例化Thread就可以创建线程,它接收方法作为参数。使用Thread.Start()就可以开启子线程,让其去执行方法中的内容。

static void Main(string[] args)
{            
    // 新创建的线程中输出
    Thread oneThread = new Thread(PrintNumber);
    oneThread.Start();

    // 主线程中输出
    PrintNumber();
    Console.ReadKey();
}

static void PrintNumber() 
{
    Console.WriteLine("开始......");
    for (int i = 0; i < 10; i++)
    {
        Console.WriteLine(i);
    }
}

主线程和子线程同时输出

可以看到当我们在子线程和主线程中同时输出PrintNumber()中的内容时,它是乱的随机交叉输出的。

2、暂停线程

暂停线程故名思意就是让线程暂停,不让其占用CPU资源,在一直等待,啥时候取消暂停就恢复运行。在C#中暂停就是让这个线程进入睡眠状态,让其休眠,不让其占用系统资源就可以了。

Thread.Sleep(TimeSpan.FromSeconds(2));    //睡眠2s

3、线程等待

线程等待就是多个线程在处理某个任务时,某个线程必须等待前一个线程处理所有数据后才可以进行执行,在这个期间,这个线程是阻塞状态的。只有前一个线程完事了,他才可以再继续执行。

static void Main(string[] args)
{            
    // 新创建的线程中输出
    Thread oneThread = new Thread(PrintNumber);
    oneThread.Start();
    oneThread.Join();

    // 主线程中输出
    PrintNumber();
    Console.ReadKey();
}

也就是说上面的程序主线程必须得等oneThread线程执行完PrintNumber方法后,它才可以执行。

4、线程终止

就是线程在执行过程中,利用某些操作(Thread.Abort())可以使其线程立即退出,不进行工作了。

static void Main(string[] args)
{            
    // 新创建的线程中输出
    Thread oneThread = new Thread(PrintNumber);
    oneThread.Start();

    Thread.Sleep(TimeSpan.FromSeconds(6));
    oneThread.Abort();

    // 主线程中输出
    PrintNumber();
    Console.ReadKey();
}

上面的程序可以看到,当主程序再等待6s后,立即将oneThread线程终止掉。

其实Abort()方法是给线程注入了ThreadAbortException方法,导致线程被终结,这其实很危险,因为该线程可能正在处理某些重要的数据,比如接收传输数据等,这样子就传递摧毁了程序,数据也就丢失了。还有就是这个方法不能保证100%终止线程。有时候有些异常会被吃掉,我们可以利用某些关键变量在子线程中进行控制,从而取消线程的执行就可以。

任务

C#中的任务与线程的区别不是很大,因为C#的任务就是 基于线程 实现的,而任务比线程更友好,使用也更方便,当然使用也更加复杂。不过对于开发者而言,任务 取消了线程的状态切换,只保留了有限的一部分。而且,在C# 更推荐使用任务,任务也是对线程的 进一步抽象和改进

创建一个任务

如线程相同的一点是,任务的创建也是通过传递一个方法(严格上讲是一个委托)。不同的是,线程的委托没有返回值而且也不接受从线程返回的值,而任务则不同,调用方可以期待任务是有返回值的而且也可以正常使用。

我们先来看看任务是什么,任务的命名空间System.Threading.Tasks,任务的类有以下两种声明:

public class Task : IAsyncResult, IDisposable;
public class Task<TResult> : System.Threading.Tasks.Task;

第一个,没有泛型的Task类表示一个没有返回值的任务;

第二个,泛型Task类表示该任务有一个返回值,返回值的类型为传递进来的泛型参数。

两个任务类的初始化类似于Thread类,不过与之不同的是 泛型Task的参数是Func,都有一个带Object参数的委托。

与线程不同,任务的创建就有很多种方法:

1、通过构造函数创建

var task1 = new Task(() => { });
var task2 = new Task<int>(()=> 
{
    int i = 0;
    return i;
});

2、使用任务工厂:

var task1 = Task.Factory.StartNew(() => { });
var task2 = Task.Factory.StartNew(() =>
{
    int i = 0;
    return i;
});

3、通过Task.Run创建:

var task1 = Task.Run(() => { });
var task2 = Task.Run(() =>
{
    int i = 0;
    return i;
});

以上三种方式创建的任务是等效的。当然实际上任务的创建并非只有这么几种,但这几种是任务创建的基础,使用频率相当高。

执行任务

与线程不同的是,任务创建完成之后就会自动执行,不需要调用方法。

关于任务的运行有以下需要注意的地方:

  1. 任务的运行不会阻塞主线程;
  2. 主线程结束后,任务一定也会结束;

任务可以IsCompleted属性确定任务是否执行完成,所以可以通过访问任务对象的IsCompleted确认该任务是否执行完成,但有一个问题,这个属性只会表示当前任务是否完成。所以如果需要等待任务完成,则可以通过访问Wait()方法,强制主线程等待任务结束。

如果使用的任务是泛型Task也就是待返回值的任务,可以通过访问Result属性获取任务执行结果。有意思的地方就是,这个属性能获取到结果的时候,也是任务执行完成的时候,所以不需要调用Wait()IsCompleted来判断任务是否完成。

注:通过构造方法创建的任务需要调用 Start方法才能启动,而通过Task.Run和Task.Factory.StartNew创建的则不需要。