C# 异步问答集

问:调用方法(callingmethod):该方法调用异步方法,然后在异步方法执行其任务的时候继续执行(可能在相同的线程上,也可能在不同的线程上)。为什么会在不同的线程上?
    答:在调用异步方法后,调用方法不会立即等待异步方法完成。相反,它会继续执行,无论是在相同的线程上还是在不同的线程上。线程切换的原因如下:
    线程池调度:
        异步方法通常在不同的线程上执行,由线程池管理。当调用异步方法时,线程池会将它分配给一个可用线程。
    协同程序:
        C# 中的异步编程模型使用协同程序,它允许在不同的线程上暂停和恢复代码执行。调用方法可以暂停并等待异步方法完成,然后在不同的线程上恢复执行。
    上下文切换:
        如果调用方法在 UI 线程上执行,它可能会在异步方法执行期间切换到不同的线程。这是因为 UI 线程通常需要处理用户交互,而异步方法可能会阻塞 UI。
    因此,当异步方法执行完成时,调用方法可能会在与最初调用它时不同的线程上继续执行。这取决于异步方法的执行方式、线程池调度以及上下文切换。
    
    问:异步调用时线程的变化?
    答:在调用异步方法时,当前线程的控制权会交给控制器,然后异步方法会在另一个线程上执行。(注意这个另一个线程一般与原始的线程不相同,特别是UI线程上调用异步时,肯定与原UI线程不一样。其它的可能一样,因为都由线程池统管,在分配、优化时,速度很快,可能分配与原始线程相同,但机率很小)。
    
    当异步方法调用完成(但可能异步任务仍然在执行)返回到原来异步方法的下一条语句时,控制器会再次分配 线程来执行。若原线程是UI线程,则控制器必定分配的是原来的UI线程,或者说将控制权还给原始线程(UI线程)。这是因为在UI线程中,控制器会确保将控制权还给原始UI线程,以便保持UI的响应性,避免UI线程被阻塞,导致用户界面无法响应用户的输入和操作。

    在其它线程中,如在控制台应用程序中,异步方法的下一条语句可能在不同的线程上执行,这取决于异步方法的设计和实现。控制器可能会将控制权分配给其他线程来执行异步方法的下一条语句。这种行为是为了充分利用多线程并发执行的能力,提高应用程序的性能。
    
    特别注意:控制器是智能的,当调用异步时,会自动判断当前线程是否交还给线程池回收,如果是回收,异步返回时,对于非UI线程将会从线程池里重新分配线程执行当前方法后面的语句。也有可能交还给线程池时,不回收只是挂起,下次异步返回时,直接将这个线程还给原方法执行下一句,这种情况线程就没有发生变化。
    
    决定控制器将当前线程挂起而不是回收的原因有:当前线程正在执行少量计算,并且没有其他需要执行的任务;线程池中没有可用的线程;回收当前线程并分配一个新线程的成本高于让当前线程继续执行的成本。等等,因为回收线程并分配一个新线程是一个相对昂贵的操作。如果控制器确定当前线程可以继续执行,它可能会选择将其挂起,而不是回收它。
    
    
    问:什么是控制器?
    答:控制器是一个统称,用来描述负责调度和管理异步方法执行的组件或机制。线程池是实际负责调配和管理线程的机制,是控制器的一种实现方式。

    线程池作为控制器的一种实现方式,负责维护一组可用的线程,并在需要时从线程池中选取一个线程来执行异步方法。线程池会根据系统的配置和资源状况,动态地管理线程的创建、销毁和复用。线程池也可以负责处理线程的优先级、执行顺序以及异步方法的结果和回调。

    除了线程池,还有其他形式的控制器可以实现异步方法的调度和管理。例如,操作系统可以提供自己的调度机制,或者应用程序可以自定义控制器来满足特定的需求。无论使用何种机制,控制器的作用是确保异步方法的执行和管理,以提高应用程序的性能和资源利用率。
    
    
    问:这个状态机是不是类似调用函数前的压栈,会保存当前的所有上下文件、变量值等,返回时再恢复这些环境,只不过可能前后的使用的线程不一样,压栈前线程可能是3,恢复时线程可能是2?
    答:是的,异步方法的实现机制中使用了状态机的概念,它在执行过程中会保存当前的执行上下文、变量值等信息,并在需要的时候恢复这些环境。

    在调用异步方法时,编译器会将异步方法转化为一个状态机类,并将异步方法的上下文、变量值等保存在状态机类的字段或局部变量中。这些保存的信息包括当前执行的位置、局部变量的值、等待的异步操作等。当异步操作完成后,状态机类会根据保存的信息来恢复执行上下文,并继续执行。

    异步方法的状态机类在执行时可能会在不同的线程上执行,这取决于异步操作的调度机制和当前可用的线程资源。在异步方法的执行过程中,它可能会在调用异步操作时挂起,并释放当前线程,让其他任务继续执行。当异步操作完成后,状态机类会根据异步操作的结果和之前保存的信息,选择一个可用的线程来恢复执行。

    总结,异步方法的实现机制通过状态机的概念,在执行过程中保存和恢复执行上下文和变量值,使得异步方法可以在挂起和恢复的过程中保持正确的状态,并且可以在不同的线程上执行,以提高并发性能和资源利用率。


    问:异步方法中的形参为什么不能为ref或out?
    答:在C#中,异步方法的参数不能使用out或ref修饰符,这是由于异步方法的特性和实现机制所决定的。

    异步方法是通过生成状态机来实现的,编译器会生成一个状态机类,用于保存异步方法的状态和执行上下文。这个状态机类在编译时会对异步方法的参数进行重构,将其转化为状态机类的字段或局部变量。因此,异步方法的参数在状态机类中是只读的,不能使用out或ref修饰符。
    out和ref修饰符用于改变参数的传递方式,out用于传递输出参数,ref用于传递引用参数。这些修饰符要求参数在方法调用之前必须被赋值,而异步方法的参数在方法调用之前会被传递给状态机类,因此不能使用这些修饰符。

    如果需要在异步方法中修改参数的值,可以通过返回值来实现。可以将需要修改的值作为方法的返回值,并使用Task<T>或ValueTask<T>来包装返回值,然后在调用异步方法后获取返回值。
    
    
    问:返回类型为Task的方法里面必须写return吗?
    答:对于返回类型为Task的方法,可以省略最后的return语句,因为方法的结束本身就会隐式地返回一个Task对象,表示异步操作已完成。而如果在方法中间使用return语句,会导致方法的提前结束,不再执行后续的代码。
    
    另外,return不必带参数。Task表明调用方法不需要从异步方法中获取返回值,但需要能够检查异步方法的状态,异步方法则返回一个类型为Task的对象,这个参数就不必写了。


    问:在net8中的task.result属性已经废除?
    答:是的。在 .NET 5 及更高版本中,Task.Result 属性已被废弃。在之前的版本中,Task.Result 属性用于获取异步操作的结果,但它存在一些潜在的问题,比如可能会导致死锁。因此,从 .NET 5 开始,推荐使用更安全和可靠的方式来获取异步操作的结果。

        public Task<int> GetNumberAsync()
        {
            //异步操作
            Task<int> task = SomeAsyncOperator();
            int res = task.Result;
            return task;//Task<int>
        }

    Task.Result属性是同步阻塞等待的.如果在等待异步操作完成时,当前线程是UI线程或主线程,同时异步操作在调用线程上执行,那么就可能发生死锁。这是因为Result属性会阻塞当前线程,等待异步操作的完成,但异步操作需要在当前线程上执行,导致了相互等待的情况。

    为了解决这个问题,推荐使用await关键字来等待异步操作完成,而不是使用Task.Result属性。await关键字会将控制权返回给调用方,不会阻塞当前线程,从而避免了潜在的死锁情况。

        public async Task<int> GetNumberAsync()
        {
            // 异步操作...
            Task<int> task = SomeAsyncOperation();
            int result = await task;
            return result;//int
        }

注意上面返回的是int,但实际应该返回是Task<int>类型,用int时会隐式自动包装成Task<int>。除了这个int值外,还会有异步操作的一情况情况,比如:任务的状态(Status 属性)、异常(Exception 属性)等。但不能返回Task<string>等。
    
    
    参照下面例子,回答后面的问题

    internal class Program
    {
        private static void Main(string[] args)//g
        {
            Task<int> value = DoAsync.CaluculateSumAsync(5, 6);//a
            Console.WriteLine("{0}", value.Result);//b
        }
    }

    internal class DoAsync
    {
        public static async Task<int> CaluculateSumAsync(int i1, int i2)//c
        {
            int sum = await Task.Run(() => GetSum(3, 4));//d
            return sum;//e
        }

        private static int GetSum(int i1, int i2)//f
        {
            return i1 + i2;
        }
    }

    (1)问:g处为什么没有加上async,a处已经使用了Task<int>?
    答:a处语句是同步调用它。Task<int> 只表示了返回类型是 Task<int>,而不代表它是异步执行的。在这里它只是启动了一个异步操作,但并没有等待它完成。就好似餐馆老板很忙(同步),但它叫了(同步)服务员去买烟(服务员去买烟是异步)。因此对于老板来说,Main 方法前面不需要加 async,因为它没有使用 await 关键字来等待异步操作(服务员买烟)的完成。
    
    
    (2)问:既然前面是同步执行,那么后面b处为什么又出结果了?
    答:value.result是一个同步阻塞动作,必须取得异步结果才执行下一语句。
    
    调用 CaluculateSumAsync 方法后,系统会先返回一个 Task<int> 对象,表示"这个异步操作将来会有一个 int 结果"。这个返回的Task<int> 对象代表了异步操作的"未来结果"。尽管它看似是null,但当你通过 Result 属性访问这个结果时,如果异步操作还未完成,它会自动等待直到完成,最终返回它是有值的,所以刻意查看时,它又是有值的。

    这里存在一种延迟执行的机制,先返回一个"未来值"的对象,后续可以通过这个对象来获取真实的结果值。这种机制使得异步编程变得更加简单,你无需手动跟踪异步操作的状态。这样异步让它去执行(可能调度时不是立即执行)就行了,又不影响我们继续向下的同步执行,提高了执行效率。
    
    所以到了b处,就把a处的调用的异步强制执行完成(value.result),同步等待它的返回结果。就相当于前面a处买了债券,到了b处强制对现一样。
    
    (3)问:c处为什么有async?
    答:因为后面的await等待异步结果的返回,所以必须加async标注有异步。await只能在一个async 法中使用,或者说有await必然有async,但有async不一定有await。
    
    注意:async不起异步作用,它是告诉后面有异步操作,相当于一个注释。哪怕是同步的方法中标注async也不影响同步的执行。
    
    (4)问:详细解说d处,返回值,参数,为什么后面调用的不是f不是异步?
    答:await Task.Run(() => GetSum(3, 4))中用了await,将会“剥离”Task,即Task.Run返回类型是Task<T>时,用了await则是T类型;如果Task.Run返回的是Task,用了await则是void类型,此时就象调用一个没有返回值的方法一样。

	public async Task DoSomethingAsync()
	{
		await Task.Run(() =>
		{
			Thread.Sleep(1000);
			Console.WriteLine("异步操作完成");
		});
	}

	public async Task Main()
	{
		await DoSomethingAsync();
		Console.WriteLine("异步操作执行完毕");
	}

    Task.Run是一个静态方法,它用于将一个操作派发到线程池中执行,并返回一个Task对象,表示这个操作的异步执行情况。
    () => GetSum(i1, i2) 是一个lambda表达式,用于向Task.Run传送委托,在这里是无参委托。
    系统预定了两个委托形式:
    (1)Action()  无返回值,输入参数可以是0-16个。
    (2)Func()    有返回值,输入参数可以是0-16个。
    () => GetSum(i1, i2)就是用了Func<int>签名(无参但有返回值int)的lambdar表达式。
    
    
    await是异步等待,不会阻塞当前线程。它在UI线程与其它线程时有一定的差异。
    其它线程:
    比如控制台等其它线程。执行时,发起调用异步,保存上下文(现场)等,上交自己的控制权(把线程交还给控制器,到线程池统一分配)。当异步完成时,线程池又重新分配线程,恢复上下文等,执行当前的下一条语句,因此此时线程可能不是原来的线程。
    
    UI线程:
    UI线程中比较特殊,它是消息处理机制,界面及后台的处理都是一个线程UI线程在处理,但它们都按消息排队处理的方式来,谁在前就处理谁(或者还有优先级),当前若使用异步,则UI线程就不会处理这个消息,会保存上下文等,跳过并处理下一个消息。当异步完成时,这个又会在消息中排队,等到UI处理这个消息时,就会恢复上下文,并继续处理当前语句的下一条语句。
    
    可以看到,其它线程恢复时,线程不一定是原来的线程。但对于UI线程来说,UI线程一直存在并不上交控制器,因此异步返回后它仍然是UI线程。
    
    
    问:ValueTask<T>是什么?
    答:它同Task<T>一样。都是异步返回结果。
    不同的是,ValueTask<T>返回的是值类型的,且不带异步返回的结果状态。而Task<T>返回的引用类型且有异步执行的状态返回(即使用的T,也会隐式包装成Task<T>以反应异步执行状态,如取消、异常等,所以仍然是引用类型)。
    
    同理,Task与ValueTask一样,后者返回是void但不带异步状态等。
    
    ValueTask与ValueTask<T>一般用于值类型(如int,bool,结构体等),且用于结果很可能已经可用的异步操作,比如,从一个缓存中读取数据;执行一个非常快速的数据库查询;调用一个同步方法,该方法返回一个值。在这些情况下,结果可能已经存储在内存中,或者可以通过非常快速的计算获得。使用 ValueTask<T> 可以避免创建 Task<T> 对象,从而节省堆分配开销和提高性能。可用是指异步操作执行后可以很快得到结果。这并不意味着在异步操作开始之前所有数据都已准备就绪。相反,它意味着异步操作本身执行得非常快,并且结果可以立即获得。
    
    
    
    问:GetAwait()是什么?
    答:GetAwaiter 方法是 System.Runtime.CompilerServices.IAsyncStateMachine 接口的一部分。它允许异步方法暂停其执行,并在结果可用时恢复执行。
        public ValueTaskAwaiter<T> GetAwaiter()
    其中 T 是异步方法返回类型的泛型参数。
    
    在大多数情况下,GetAwait() 方法是隐式调用的。当使用 await 关键字等待异步操作时,编译器会自动调用 GetAwait() 方法。

		async Task MyMethodAsync()
		{
			await Task.Delay(1000);//隐式调用Task.GetAwaiter()
		}

    当使用 Task.Result 同步阻塞调用任务时,编译器也会隐式调用 GetAwait() 方法。Task.Result 属性实际上是一个扩展方法,它使用 GetAwaiter() 方法来获取任务的结果。    

    async Task MyMethodAsync()
    {
        int result = await Task.FromResult(42);//隐式调用Task<int>.GetAwaiter()
    }


    
    
    问:下面代码有几次异步?

    internal class Program
    {
        private static void Main(string[] args)
        {
            ValueTask<int> value = DoAsync.CaluculateSumAsync(5, 6);//a
            Console.WriteLine("{0}", value.Result);//b
        }
    }

    internal class DoAsync
    {
        public static async ValueTask<int> CaluculateSumAsync(int i1, int i2)//c
        {
            int n = 3;//d
            int sum = await Task.Run(() => GetSum(3, 4));//e
            return sum;//f
        }

        private static int GetSum(int i1, int i2)//f
        {
            return i1 + i2;//g
        }
    }


    答:只有一次异步。流程如下:
    
    (1)假定进入Main的是线程3,主线程3执行到a处时,会同步调用c;
    
    (2)c处虽然有async,但它只是异步的一个标注,并不会产生异步。(同步中也可以标记为async,不起实质作用,它只是告诉编译器,有异步,也就是吼两声,但内部有没有,真正执行不,不起任何作用。就象一个危险告示牌,至于危险发不发生,它不起决定性作用。)
    
    (3)线程3同步执行到c后,继续同步执行到d,继续执行到e,这里有就变化了。
    
    (4)Task.Run本身并不是异步,它只是告诉线程池分配一个新的线程(原线程3已经被占用)来执行GetSum,假定这个线程是线程5。主线程(线程3)会在这里被挂起,会被释放,不会等待异步操作完成,控制权暂时交还给Main方法,返回Main中继续执行b处的代码。
    
    注意:在e处由于使用了await,会构建并返回一个ValueTask<int>类型的"未来结果承诺对象",它代表了异步操作最终将产生的int结果。而不是直接返回实际的int结果值。线程3返回的“未来结果”的变量类型是由c处方法返回类型决定。因此它返回时,value的值就有了一个未来的值。这个返回值与await中(e处)的返回值与类型没有一毛钱的关系。
    
    两个要点:
    异步:这里线程5(由task.run引发)与线程3返回到Main方法,继续执行b处,这两者同时执行,不相互影响,故这种状态称异步,单一的线程5或单一的线程3不能算异步,必须至少有两个“任务”同时在执行,才称为异步。
    
    挂起:就是暂时,将当前方法的执行状态保存下来,不继续执行,将控制权交还给调用方。这里的"暂停点"就是await关键字所在的位置,编译器会在这里插入状态机逻辑,实现挂起和恢复执行。当程序执行到await处时,会标记一个"暂停点",然后原线程(如主线程3)会返回到调用方(这里是Main方法),继续执行后面的代码(如b处)。
    
    (5)主线程3执行到b处时,遇到value.Result,会强制同步阻塞等待结果(获取未来的结果),它不会被销毁或退出。因此就必须看e处的执行了。当e处执行完成时,线程5就上交自己给线程池进行调度(销毁或另分配)。根据await处的标注,恢复上下文等,CLR线程调度器会从线程池中分配一个新的工作线程,假设是线程7,来执行CaluculateSumAsync方法剩余的部分,即f处的return sum。线程7执行完f处的代码后,它的使命就完成了,会自动归还给线程池进行后续的线程调度和管理。
    
    (6)原来的线程3因为获得了异步操作的结果,会从阻塞状态恢复,继续执行之后的代码,即输出异步操作的结果。最后,线程3执行完Console.WriteLine后,整个主线程的执行就结束了。
    
    故异步只有一次,在e处。需要注意的是:
    async让方法支持异步操作,但不会立即触发异步;
    await触发了真正的异步操作,暂停当前方法执行,等待异步操作完成;
    Task.Run只是将同步代码包装为任务,分配给线程池异步执行,本身不是异步操作;
    当遇到await Task.Run时,才触发了异步操作的机制;
    原线程阻塞等待时,并不会被销毁,仍然存在;
    异步操作完成时,从线程池获取新线程执行后续代码;
    新线程执行完毕后,自动归还给线程池;
    原线程重新获得结果后继续执行.
    

    问:Async与await好像有点象装箱与拆箱一样?
    答:是的。不严格的说,它就是这样。
    
    async告诉编程器下面有异步可以发生,当遇到await时,当前操作就会暂停,保存上下文等,立即将结果包装成Task或Task<T>类似的返回过来,它是一个“未来对象”公文包,所以当前是值是null。之所以用Task,因为未来的值除了返回值外,还有异常状态、取消等。这个过程好像是在“装箱”。
    
    await执行完成后,就相当一个“剥离”Task的情况,有点象拆箱。比如原来是Task就是成了void,Task<T>的就成了<T>。例如:

    await Task.Run(() => Console.WriteLine(5.ToString()));//a
    Console.WriteLine((await Task.Run(() => 6)).ToString());//b
    await Task.Run(() => Task.Run(() => Console.WriteLine(7.ToString())));//c
    int value = await Task.Run(() => Task.Run(() => 8));//d
    await Console.Out.WriteLineAsync(value.ToString());//e


    上面a处Run里面是Action,所以返回值是Task,经外层await剥离后就是void;
    b处run内是func<int>返回就是Task<int>,await后就是int,输出时再转为string;
    c处内层run是action,返回是Task,因此外层run返回就Task<Task>,经外层run剥离后,得到Task,即为最终类型。
    d处内层run是一个Func<int>类型,因此返回是Task<int>类型,外层run就应返回Task<Task<int>>。经await应该剥离外层Task,返回类型是Task<int>,但为什么左侧是int value?,因此这里面还有一个隐式转换即 T a=Task<T>会自动转换过来。注意的是不能逆向,即Task<T> t=3;这种是错误的,因为Task里本身含有原任务的一些上下文信息、异步、取消标记等。如果反过来写不符合逻辑,这是一种“造假”行为。
    
    e处WriteLineAsync返回的是Task,用await剥离后就是void。
    另外e处与Console.WriteLine(value.toString());是有区别的,后者是同步,即同步阻塞等待输出完成后,继续向下执行。而前者是异步非阻塞,即e处遇await后,释放当前线程,另启线程来做后面的输出,同时返回当前线程的未来结果(Task)返回原调用处,继续执行其它任务,当输出完成后,上交当前线程给线程池,线程池或控制器另分配线程继续执行输出的下一句。
    
    
    
    
    问:现在所说的“线程池”,可能是一个模糊的概念,因为task并不是真正的线程,也许在这个“线程池”也在调控事件,I/O等?并不一定调控线程?可能本质上不能称为线程池,称为控制器更为适当?但人们一般仍然称为线程池?
    
    答:是的。本质上不能称为线程池。
    Task实际上并不是真正的线程,它只是一个表示异步操作的对象。当我们说Task进入"线程池"时,实际上是指Task被安排到了一个由操作系统管理的工作调度队列中。

    这个调度队列不仅包括需要在CPU线程上执行的Task,还包括像I/O、异步等待等各种类型的工作项。因此,它不仅仅管理线程资源,还管理其他异步资源。

    更准确的说,这应该被称为"工作调度器"或"任务调度器",而不是"线程池"。因为它调度的不仅仅是线程执行工作,还包括各种异步操作。

    但由于历史原因,".NET"社区中人们习惯将其称为"线程池"。这个术语虽然不太精确,但被广泛使用和理解。所以即使有点模糊,大家也习惯于沿用"线程池"这个传统叫法。

    因此尽管应该称为"工作/任务调度器"之类的名字会更合适,但大家常称"线程池"、“控制器”、“调试器”等这个传统称呼。
    
    另外Task是比线程更为精细的执行单元,从Task的命名空间就可以看出:System.Threading.Task。task是在线程threading后面。
    
    
    
    
    问:任务为什么只能取消一次?
    答:Task在C#中有以下几种状态:
    1. Unstarted: 这是初始状态,表示任务实例对象已经创建,但尚未开始执行。
        
    2. WaitingForActivation: 任务已经启动,正在等待执行程序启动它。
        一旦你启动了任务(通过调用Start方法或在构造函数中传入执行委托Task.Run),它的状态就会从Unstarted变为WaitingForActivation或WaitingToRun。

    3. WaitingToRun: 任务已经排队等待线程池中的线程执行。
        WaitingForActivation该状态表示Task已准备好运行,但还在等待从线程池获取一个线程资源用于执行。处于这种状态的Task会优先获取可用线程资源。
        WaitingToRun该状态表示Task已进入了执行队列,正在等待轮到自己被执行。
        与WaitingForActivation相比,处于WaitingToRun状态的Task获取线程资源的优先级较低。
        
        WaitingToRun已经获得分配的资源,正准备执行,但因忽然来了一个优先级高的,故需要等待才执行。
        
        这就好像WaitingForActivation与WaitingToRun都排队等待线程池的调配,WaitingForActivation连线程都还没有分配,而WaitingToRun已经分配了线程,但因优先级的原因稍后执行。就如同你排队买票,是第一个正要买时,忽然来了一个90岁的老人,需要稍微等待一下。

    4. Running: 任务正在执行。

    5. WaitingForChildrenToComplete:
        如果任务创建了附加的子任务,该状态表示它正在等待这些子任务完成。
        在异步编程中,当一个异步方法内部使用await关键字等待另一个异步操作完成时,就会创建一个子任务。父任务会暂时挂起,切换到执行其他任务,直到子任务完成后才会恢复父任务的执行。

    6. RanToCompletion: 任务已经成功完成执行。

    7. Canceled: 任务已被取消。

    8. Faulted: 任务在执行过程中发生了未经处理的异常。
        注意:使用try...catch...处理了异常,不会引发Faulted。

    任务状态的改变通常由 .NET 运行时内部机制控制,例如:
        从Unstarted变为WaitingForActivation或WaitingToRun是在启动任务时发生的。
        从WaitingToRun变为Running是在任务获得线程执行时发生的。
        从Running变为其他终止状态(RanToCompletion、Canceled或Faulted)是在任务执行完成、被取消或发生异常时发生的。

    取消操作只能使用一次的原因是为了避免不确定的状态。一旦任务被取消,它就进入了Canceled状态,不能再次取消。如果允许反复设置取消状态,可能会导致混乱和不一致的行为。此外,取消操作本身是个异步操作,可能需要一些时间才能完全生效。反复调用取消可能会引入不必要的复杂性和开销。

    总的来说,任务状态的管理由.NET运行时自动处理,开发人员通常只需关注任务的创建、等待和异常处理等方面。但下面情况可以主动取消任务:
    
    1. 响应用户操作:例如在Web应用中,用户取消了某个长时间运行的请求操作。这时我们需要手动取消相关的后台异步任务。

    2. 应对超时情况:有些任务如果执行时间过长,为了避免浪费资源和阻塞,我们需要手动取消它。

    3. 终止不再需要的任务:当应用程序的状态发生变化,某些之前启动的异步任务不再需要时,我们可以选择主动取消它们。

    4. 处理错误或异常:在遇到无法恢复的异常或错误时,取消当前运行的异步任务可以让应用程序更快恢复运行。

    5. 应用程序重启或关闭:在应用重启或关闭前,我们通常需要取消所有挂起的异步任务。    
    
    问:try...catch...finally...中的finally的陷井是什么?
    答:先说结论:
        如果try或catch块中有return语句,finally块对返回变量的修改不会影响最终返回值。
        如果try和catch块中都没有return语句,finally块对返回变量的修改会影响最终返回值。
    例如:

        private static void Main(string[] args)
        {
            Console.WriteLine(ShowMsy());
        }

        private static int ShowMsy()
        {
            int a = 3;
            try
            {
                a = 3;
                //throw new Exception();//a
                Console.WriteLine("Try块");
                return a;//b
            }
            catch (Exception)
            {
                a = 4;
                Console.WriteLine("Catch块"); // 添加这一行
                return a;//c
            }
            finally
            {
                a = 5;//d
                Console.WriteLine("Finally块"); // 添加这一行
            }
            return a;//e
        }    


    上面属于第一种内部有return的情况。到了b处会执行return,这会将此时a=3的值保存到弹栈中去,又不能真正返回,因为后面的finally还要执行,因此,又返回到finally执行相关代码,注意虽然它的确是执行了a=5,前面的a=3一旦返回值被保存在栈中,后续对变量的修改就无法影响已经保存在栈中的那个返回值了。也即无法修改,因此,方法返回时弹栈出来的值就是a=3。
    
    疑问的是既然无力修改,为什么还要设计finally呢?
    那是为了保证某些必须执行的代码在方法结束时(无论正常结束还是由于异常退出)都能被执行。比如说:
    释放资源:比如关闭打开的文件、断开数据库连接、释放占用的内存等。这些代码必须执行,否则可能导致资源泄漏。
    记录日志:在方法退出前,通常需要记录一些日志信息,而这些日志记录代码就可以放在finally中。
    执行清理工作:比如撤销对系统状态的修改、恢复现场等,都可以放在finally中执行。
    释放锁:在线程同步场景下,finally可以用于确保获得的锁被正确释放。
    这些情况都必须需要finally的存在。
    
    上面如果让a句生效,则先是a=3,然后异常捕捉后a=4,再返回存栈a=4,再到finally中a=5(但这个值无力修改弹栈中的a=4),因此最终输出的是4。
    因此上面finally是无法修改弹栈中的值,因此,finally中也禁止使用return。
    
    上面情况是有return的情况,当注释掉try里面所有return后。则后面的e就会自动生效。上面try无论怎么执行,因此都没有return,不会影响弹栈,所以try出来都会经过finally中a=5,所以这时间return的就是a=5。最终输出就是5.
    
    
    
    问:为什么一个cancellationtokensource可以为多个任务创建cancellationtoken,这会不会相互影响?
    答:这是一个巧妙的设计。前者为对象,后者为结构。
    
    CancellationTokenSource可以为多个任务创建CancellationToken,这些新创建的CancellationToken彼此是相互独立的,它们不会相互影响。    每个CancellationToken都是CancellationTokenSource的一个引用,当调用CancellationTokenSource的Cancel方法时,所有持有该CancellationTokenSource的CancellationToken的IsCancellationRequested属性都会被设置为true。但是,这不会影响其他CancellationTokenSource创建的CancellationToken。
    
    一方面,只要这个统管的CancellationTokenSource进行了取消,它所以产生的所有CancellationToken结构体都会生效,不用一个一个设置。如果你想某一任务独立不被取消,只能用一个新的统管CancellationTokenSource来产生这个单独一个或多个的CancellationToken。
    
    另一方面,为什么不直接用CancellationToken构造一个新对象呢?更方便、简捷?直接就不用统管了。
    在C#中,CancellationToken是一个结构体(struct),是一种值类型。值类型的对象在创建后无法更改其状态,这意味着一旦CancellationToken被创建,它的IsCancellationRequested属性就无法被修改了。(备注:就是类似于引用类型的深复制,比如:

        struct Point
        {
            public int X;
            public int Y;
        }

        Point p1 = new Point() { X = 1, Y = 2 }; // 创建一个结构体对象p1
        Point p2 = p1; // 将p1的值复制给p2
        p2.X = 3; // 修改p2的X值为3

        Console.WriteLine(p1.X); // 输出:1,原始结构体对象p1的X值没有改变
        Console.WriteLine(p2.X); // 输出:3,修改的是副本p2的X值    


    上面p2=p1是“深复制”,修改p2不会影响p1,而在引用类型中这种一般是浅复制,假定是引用类型,则在p2.x=3时就会影响到p1.x也将是3,但值类型这种另创建的不可更改状态就有妙用。)
    
    如果我们直接构造一个新的CancellationToken对象,那么它的IsCancellationRequested属性将始终保持为false,当复制到另一处时即使修改,也无法修改原来的false也就无法实现取消操作的功能。

    相比之下,CancellationTokenSource是一个类(class),它是一种引用类型。通过CancellationTokenSource创建CancellationToken对象,我们可以在需要时调用CancellationTokenSource的Cancel方法来修改所有相关CancellationToken的IsCancellationRequested属性。
    
    简单的说,对象CancellationTokenSource产生了多个CancellationToken结构体,将CancellationTokenSource的状态复制到了CancellationToken中,因此修改CancellationToken无法修改、影响到CancellationTokenSource中的状态,这些CancellationToken只能检测CancellationTokenSource中的状态,从而达到只有一个统管进行取消,所有的CancellationToken都可以用iscancellationrequested来检测到取消状态。

    这种设计使得我们可以在异步操作的不同阶段灵活地控制取消操作,而不必事先创建多个CancellationToken对象。我们只需要创建一个CancellationTokenSource,然后根据需要从中获取多个CancellationToken即可。
    
    因此,虽然直接构造CancellationToken对象看起来更简单,但它无法满足灵活控制取消操作的需求。使用CancellationTokenSource创建CancellationToken对象是一种更加灵活和可控的方式,符合异步编程的需求。    
    
    
    问:为什么cancellationtoken设计为值类型?
    答:设计为结构体有以下主要原因:
    1.线程安全:值类型天生不可变,在多线程环境可避免并发引起的问题,若是引用类型,就需要额外用锁等机制保证线程安全,这会增加复杂性和开销。
    2.低内存占用和传递简单:值类型占用的内存通常比引用类型少,特别是在创建大量实例时。将CancellationToken设计为值类型可以节省内存开销。同时,将一个小的值类型传递给方法比传递引用类型对象要简单和高效。
    
    3.不可更改的状态:CancellationToken的核心功能是表示取消状态,这种状态本身就是不可变的。一旦发出取消请求,它的状态就不应被修改。值类型可以很好地表达和保证这种不可变性。

    4.避免别名问题:使用值类型可以避免别名问题。如果CancellationToken是引用类型,多个变量可能指向同一个实例,当其中一个变量更改状态时,其他变量也会受到影响,这可能导致意外行为。值类型则不存在这个问题。    5.设计一致性:.NET 中许多用于表示不可变状态的类型都是值类型,比如DateTime、TimeSpan等。将CancellationToken设计为值类型与这种习惯保持一致。
    
    
    问:Task.Run(a,b)其中第二个参数b是取消任务标志,有b和没有b两者有什么区别?
    答:对于是否带第二个参数取消标志没有区别。有第二个取消标题唯一的好处就是,你不需要另外再传取消标志,同时让别人也更易阅读:此任务带了取消标志。

    internal class Program
    {
        private static void Main(string[] args)
        {
            CancellationTokenSource cts = new CancellationTokenSource();
            CancellationToken ct = cts.Token;
            Console.WriteLine("主线程开始....");
            MyClass mc = new MyClass();
            Task t1 = mc.RunAsyncWithCancel(ct);
            Task t2 = mc.RunAsyncWithOut(ct);

            Thread.Sleep(3000);
            cts.Cancel();

            try
            {
                Task.WaitAll(t1, t2);
            }
            catch (Exception)
            {
                Console.WriteLine("有异常捕获");
            }

            Console.WriteLine("任务全部完成!");
        }
    }

    internal class MyClass
    {
        public async Task RunAsyncWithCancel(CancellationToken ct)
        {
            await Task.Run(() => CycleMethod("t1---", ct), ct);//a
        }

        internal async Task RunAsyncWithOut(CancellationToken ct)
        {
            await Task.Run(() => CycleMethod("t2", ct));//b
        }

        private void CycleMethod(string taskname, CancellationToken ct)
        {
            Console.WriteLine($"{taskname}开始========");
            for (int i = 0; i < 7; i++)
            {
                if (ct.IsCancellationRequested)
                {
                    Console.WriteLine("收到请求取消,准备退出");
                    return;
                }
                Thread.Sleep(1000);
                Console.WriteLine($"{taskname}第{i}次 output");
            }
            Console.WriteLine($"{taskname}结束======");
        }
    }


    上面带不带参数都是一样的取消。即使不带参数,通过前面的委托带入这个标志,一样可以检测,一样可以取消。
    
    
    问:Task中的取消需要人为输入检测代码吗?
    答:有时需要,有时不需要。有点不好说。
    看一个例子吧,

    private static async Task Main(string[] args)
    {
        CancellationTokenSource cts = new CancellationTokenSource();
        CancellationToken ct = cts.Token;
        Task t = Task.Run(async () =>
        {
            for (int i = 0; i < 10; i++)
            {
                if (ct.IsCancellationRequested)//d
                {
                    Console.WriteLine("检测到任务取消");
                    ct.ThrowIfCancellationRequested();//a  推荐
                }
                Thread.Sleep(1000);
                Console.WriteLine(i.ToString()); //e
            }
            Console.WriteLine("执行完");//f
        },ct);//h
        Thread.Sleep(3000);
        cts.Cancel();
        try
        {
            t.Wait(ct);//c
        }
        catch (Exception ex)//d
        {
            Console.WriteLine("已经取消");
        }
    }    


    结果是:
    0
    1
    已经取消
    说明:上面d处尽管有人为代码检测,但实际上因为c处是t.wait(ct),除了检测本身的异常外,因为带了ct,它更优于任何异常先检测是否有取消,所以上面你看不到“检测到任务取消”。那是因为“系统”会自动、更快于a处检测到OperationCanceledException而退出异步中,被后面捕捉到。
    
    如果c处换为t.wait();则结果是:
    0
    1
    2
    检测到任务取消
    已经取消
    说明:因为没有ct,就没有"系统"优先检测。只能按人为代码逐个检测。因此在2(3秒后)先同步显示“检测到任务取消”,再由c处抛出OperationCanceledException,退出异常前会将异常封装到AggregateException(总异常)中,由外面检测AggregateException这个异常。
    
    因此:t.wait(ct)与t.wait()有两个区别:
    (1)前者带ct,会有“系统”优先检测取消(不必看人为的代码)而直接抛出OperationCanceledException。对于其它异常,它也会抛出,但会包装到AggregateException再抛出。
    (2)后面不带ct,即使ct取消,也不会生效,它只会由任务中人为的检测代码(d处)来检测,并由a外进行处理(抛出异常)。再包装到AggregateException供外面捕捉。
    
    注意:
    (1)a处一般推荐使用抛出异常的方式来退出,这是符合逻辑。尽管可以用return,但它无法知道任务内是正常或异常退出。
    (2)一般重要的代码前先进行检测取消(d处),再在后面执行。若有多段,为了及时响应取消,应在每段开始前检测取消标志,再执行。
    
    
    
    问:async void与async Task有什么区别?
    答:async void与普通的void没有什么区别,别看前面加了一个async,但它仍然是一个普通方法。
    而async Task却是一个异步的任务,返回时一般是未来的一个void,带有一些执行状态,比如是否完成,是否异常等。
    看下面:

        private static async Task Main(string[] args)
        {
            Stopwatch sw = Stopwatch.StartNew();
            Task t1 = Task.Run(() => MySleep(1, 1000));
            Task t2 = Task.Run(() => MySleep(2, 2000));
            Task t3 = Task.Run(() => MySleep(3, 5000));

            Console.WriteLine("计时毫秒:" + sw.ElapsedMilliseconds);
            Task.WaitAll(new[] { t1, t2, t3 }, 3000);//a
            Console.WriteLine("计时毫秒:" + sw.ElapsedMilliseconds);

            Console.WriteLine("3秒结束");//b

            Console.ReadKey();//c
        }

        private static async void MySleep(int v1, int v2)//d
        {
            await Task.Delay(v2);//e
            Console.WriteLine(v1.ToString());
        }    


    结果是:
    计时毫秒:0
    计时毫秒:18
    3秒结束
    1
    2
    3
    为什么a处没有同步阻塞呢?
    其实它已经同步阻塞了,因为三个任务调用d处时,因为d处是async void,MySleep会被视为一个普通方法。在遇到await Task.Delay(v2),它会立即返回一个已完成的任务,而不会真正等待延时时间。也就是说t1,t2,t3都会在毫秒级内完成任务(结果显示是18毫秒),而且这个waitall的结果是true。所以先看到18毫秒(已经完成三个任务)然后是3秒结果,最后是里面await后的结果1,2,3.
    
    如果改为async Task那么结果是:
    计时毫秒:0
    1
    2
    计时毫秒:3017
    3秒结束
    3
    a处仍然同步阻塞,但因为返回的结果不再是前面的void,而是Task,意味着它是一个真正的异步方法,这是未来的结果,返回一个Task对象,该对象包含异步操作的状态、结果值和可能的异常信息。因此会识别里面任务并没有真正完成,它就会同步阻塞(直到3000毫秒,结果显示3017毫秒),在这其中里面有两个异步1和2已经完成,所以显示1,2,然后阻塞超时,接着“3秒结束”,最后是任务3的5秒完成。
    
    
    
    问:下面为什么不断捕捉到异常?

    private static void Main(string[] args)
    {
        Test();
        Console.ReadKey();
    }

    private static async Task Test()
    {
        var cts = new CancellationTokenSource();

        Console.WriteLine("延时10秒(按Ctr+C取消延时)...");

        try
        {
            await Task.Delay(10000, cts.Token);
            Console.WriteLine("Delay complete!");
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine("延时取消.");
        }
    }  

 
    答:Ctrl+C用于中断程序执行、取消操作以及退出应用程序等场景。作用类似于VBA中的Ctrl+Break强制中断程序的功能。按下它就相当于退出了程序,你还捕捉到异常吗?所以后面的异常是捕捉不到的。
    
    只有改成事件。
    Console.CancelKeyPress是.NET中一个用于处理控制台应用程序中 Ctrl+C 中断信号的事件。
    在控制台应用程序中,当用户按下Ctrl+C时,会触发Console.CancelKeyPress事件。这个事件可以让开发者在程序中捕获并处理这个中断信号。因此,绑定在此事件上,在退出程序前取消退出,并激活取消标志,就可以使用取消任务命令,从而引发异常。

        private static void Main(string[] args)
        {
            Console.CancelKeyPress += KeyPress_Cancel;//订阅
            Test();
            Console.ReadKey();
        }

        //全局,因为两个地方要用        //事件触发在Ctrl+C

        private static CancellationTokenSource cts = new CancellationTokenSource();

        private static void KeyPress_Cancel(object? sender, ConsoleCancelEventArgs e)//处理
        {
            e.Cancel = true;
            cts.Cancel();
        }

        private static async Task Test()
        {
            Console.WriteLine("延时10秒(按Ctr+C取消延时)...");
            try
            {
                await Task.Delay(10000, cts.Token);
                Console.WriteLine("Delay complete!");
            }
            catch (OperationCanceledException)
            {
                Console.WriteLine("延时取消.");
            }
        }


    在控制台应用程序中,使用Ctrl+C这样的快捷键来触发事件是非常常见的做法。这是因为控制台应用程序通常没有丰富的UI界面,而Ctrl+C是一个标准的中断信号,用户可以通过这个快捷键来中断正在运行的程序。
    
    整个事件的流程是:
    定义事件:事件是预定义好的Console.CancelKeyPress事件,由.NET框架提供的。
    触发事件:当用户按下Ctrl+C时,控制台检测到这个信号,并触发Console.CancelKeyPress事件。
    发布事件:当事件被触发时,.NET框架会创建一个ConsoleCancelEventArgs对象,并将其作为事件参数传递给事件处理程序。
    订阅事件:订阅了Console.CancelKeyPress事件,用+=将KeyPress_Cancel方法指定为事件处理程序。
    处理事件:当事件被发布时,KeyPress_Cancel被调用,执行了相应的操作,包括阻止默认行为,以及取消延时任务。
    
    
    问:Task.Yied()与Task.Delay(1)区别?
    答:前者是一闪而过,且是一种瞬间执行完毕的过程(但给其它任务机会)。而后面则是异步马上返回也是以相同线程执行一条语句,只不过异步中的线程会延时1毫秒后完成任务。具体的:
    Task.Yield() 不会产生任何延迟,执行完立即切回原线程继续执行。
    Task.Delay(1) 会在后台异步执行延迟操作,但不会阻塞当前线程,自己完成后也不影响原调用线程。
    
    await Task.Yied()与await Task.Delay(1):
    前者与Task.Yied()基本相同,因为都是很快切换回来(可以认为瞬间),加上await则更易阅读理解。
    后者则会异步阻塞当前,当1毫秒异步完成后返回来重新分配线程继续向下执行(可能与原线程相同)。
    
    await Task.Yied()好处,就是可以释放“霸占”的当前线程,给外面的任务一个执行的“喘气”机会,不然会感觉死机一样,影响用户体验。这点有点象VB.net中的DoEvent一样:用于让出当前线程的执行权,让其他可运行的任务或事件有机会执行。
    

  • 21
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值