C# 学习笔记(17)—— 多线程编程

2023-10-20 18:52:47 浏览数 (1)

多线程

进程和线程的概念

当我们打开一个应用程序后,操作系统就会为该应用程序分配一个进程ID,例如打开Word时,你将在任务管理器虚的进程选项卡中看到WINWORD.EXE进程

进程可以理解为一块包含了某些资源的内存区域,操作系统通过进程这一方式把它的工作划分为不同的单元。一个应用程序可以对应多个进程,例如在打开Chrome浏览器时,任务管理器的应用程序选项卡中只有一个Chrome应用程序,而在进程选项卡中却又多个chrome.exe进程

线程是进程中独立执行单元,对于操作系统而言,它通过调度线程来使用应用程序工作。一个进程中至少包含一个线程,我们把该线程称为主线程。线程和进程之间的关系可以理解为:线程是进程的执行单元,操作系统通过调度线程来使应用程序工作;而进程则是线程的容器,它由操作系统创建,又在具体的执行过程中创建了线程。

线程的调度

生活中,要想在吃饭的时候看电视,你需要来回切换这两个动作,它们时由你来进行调度的。在计算机里,线程就相当于你的动作,操作系统就相当于你,操作系统需要调度线程使它们轮流工作。

在操作系统课程中,老师会介绍说“Windows是抢占式多线程操作系统”。之所以说它是抢占式的,是因为线程可以在任意时间里被抢占,来调度另一个线程。操作系统为每个线程分配了0~31中的某一级优先级,而且会把优先级高的线程优先分配给CPU去执行。

Windows 支持七个相对线程优先级:IDle、lowest、BelowNormal、Normal、AboveNormal、hightest 和 Time-Critical。其中,Normal 是默认的线程优先级。程序可以通过设置 Thread 的 Priority 属性来改变线程的优先级,该属性的类型为 ThreadPriority 枚举类型,其成员包括lowest、BelowNormal、Normal、AboveNormal、hightest

成员名称

描述

Lowest

可以将Thread 安排在其他任何优先级的线程之后

BelowNormal

可以将Thread安排在具有Normal优先级的线程之后,在具有Lowest优先级的线程之前

Normal

可以将Thread安排在具有AboveNormal优先级的线程之后,在具有BelowNormal优先级的线程之前。默认情况下,线程具有Normal优先级

AboveNormal

可以将Thread安排在具有Highst优先级的线程之后,在具有Normal优先级的线程之前

Highest

可以将Thread安排在具有任何其他优先级的线程之前

线程也分前后台

线程由前台线程和后台线程之分。在一个进程中,当所有前台线程停止运行后,CLR 会强制结束所有仍在运行的后台线程,这些后台线程被直接种植,却不会抛出任何异常。主线程将一直是前台线程。我们可以使用Thread类来创建前台线程

下面代码演示了前台线程和后台线程之间的区别:

代码语言:javascript复制
using System;
using System.Threading;

namespace Demo
{
    class Program
    {
        static void Main(string[] args)
        {
            Thread backThread = new Thread(Worker);
            backThread.IsBackground = true;
            backThread.Start();
            Console.WriteLine("从主线程退出");
        }

        public static void Worker()
        {
            Thread.Sleep(1000);
            Console.WriteLine("从后台线程退出");
        }
    }
}

以上代码首先通过Thread类创建一个线程对象,然后通过设置IsBackground属性来指明该线程为后台线程。如果不设置IsBackground属性,则Thread类所创建的线程将默认为前台线程。

接着,程序会调用Start函数来启动该线程,此时后台线程会执行Worker函数的代码。在Worker函数中,为了体现出前台线程与后台线程的区别,这里调用了Sleep使该后台线程睡眠1秒,然后再执行。

从前面的分析可以看出,该控制台程序有两个线程,一个是运行Main函数的主线程,另一个是运行Worker函数的后台线程。由于前台线程执行完毕后CLR会无条件地终止后台线程地运行,所以在前面地代码中,若启动了后台进程,则主线程将会继续执行。

主线程运行完Console.WriteLine("从主线程退出")语句后就会退出。此时CLR发现主线程运行结束后,则会种植后台线程,然后使整个应用程序结束运行。所以Worker函数中地Console.WriteLine("从后台线程退出")语句将不会执行

如果我们想要代码执行,有3种办法:

1、将所创建的线程设置为非后台线程

2、将主线程在后台线程执行完再执行(Thread.Sleep(1000))

3、在主函数中调用Join函数的方法,确保主线程会在后台线程执行结束后才开始运行

代码语言:javascript复制
using System;
using System.Threading;

namespace Demo
{
    class Program
    {
        static void Main(string[] args)
        {
            Thread backThread = new Thread(Worker);
            backThread.IsBackground = true;
            backThread.Start();
            backThread.Join();
            Console.WriteLine("从主线程退出");
        }

        public static void Worker()
        {
            Thread.Sleep(1000);
            Console.WriteLine("从后台线程退出");
        }
    }
}

以上代码在调用backThread.Join来确保主线程会在后台线程结束后再运行。这种方式虽然涉及线程同步的概念:再某些情况下,需要两个线程同步运行,即一个线程必须等待另一个线程结束后才能运行。

在前面的代码中,我们使用了Thread(ThreadStart)构造函数来创建线程对象。除此之外,Thread类还提供了另外3个构造函数,它们分别为Thread(ParameterizedThreadStart start)Thread(ParameterizedThreadStart start, int maxStackSize)Thread(ThreadStart start, int maxStackSize)。其中ParameterizedThreadStartThreadStart都是委托类型

线程的容器——线程池

前面我们都是通过Thread类来手动创建进程的,然而线程的创建和销毁都会耗费大量时间,这样的手动操作将造成性能损失。因此,为了避免因通过Thread手动创建线程而造成的损失,.Net引入了线程池机制

线程池

线程池是指用来存放应用程序中要使用的线程集合,你可以将它理解为一个存放线程的地方,这种集中存放的方式有利于对线程进行管理。

CLR初始化时,线程池中没有线程的。在内部,线程池维护了一个操作请求队列,当应用程序想要执行一个异步操作时,你需要调用QueueUserWorkItem方法来将对应的任务添加到线程池的请求队列中。线程池实现的代码会从队列中提取任务,并将其委派给线程池中的线程去执行。

如果线程池中没有空闲的线程,线程池就会创建一个新线程去执行提取的任务。而当线程池线程完成了某个任务时,线程也不会被销毁,而是返回线程池中,等待响应另一个请求。由于线程不会被销毁,所以就避免了由此产生的性能损失

这里需要明确一点:由线程池所创建的线程是后台线程,且它的优先级默认为Normal

通过线程池来实现多线程

要使用线程池中的线程,需要调用静态方法ThreadPool.QueueWorkItem,以指定线程要调用的方法,该静态方法有两个重载版本:

代码语言:javascript复制
public static bool QueueUserWorkItem(WaitCallback callBack);
public static bool QueueUserWorkItem(WaitCallback callBack, Object state);

这两个方法用于向线程池队列添加一个工作项(work item)以及一个可选的状态数据。然后,这两个方法就会立即返回。工作项是指一个由callback参数标志的委托对象,被委托对象包装的回调方法将由线程池来执行。传入的回调方法必须匹配System.Threading.WaitCallback委托类型,该委托定义为:

代码语言:javascript复制
public delegate void waitCallbak(object state);

下面通过实例来延时如何使用线程池来实现多线程编程,具体的演示代码如下:

代码语言:javascript复制
using System;
using System.Threading;

namespace Demo
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("主线程ID = {0}", Thread.CurrentThread.ManagedThreadId);
            ThreadPool.QueueUserWorkItem(CallBackWorkItem);
            ThreadPool.QueueUserWorkItem(CallBackWorkItem, "work");
            Console.WriteLine("从主线程退出");
        }

        public static void CallBackWorkItem(object state)
        {
            Console.WriteLine("线程池线程开始执行");
            if (state != null)
            {
                Console.WriteLine("线程池线程ID = {0} 传入的参数为 {1}", Thread.CurrentThread.ManagedThreadId, state.ToString());
            }
            else
            {
                Console.WriteLine("线程池线程ID = {0}", Thread.CurrentThread.ManagedThreadId);
            }
        }
    }
}

协作式取消线程池线程

.Net Framework 提供了取消操作的模式,这个模式是协作式的。为了取消一个操作,我们必须首先创建一个System.Threading.CancellationTokenStateSource对象

下面的代码演示了协作式取消的使用方法,主要实现了用户在控制台下敲下回车键后就停止计数的功能:

代码语言:javascript复制
using System;
using System.Threading;

namespace Demo
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("主线程运行");
            CancellationTokenSource cts = new CancellationTokenSource();
            ThreadPool.QueueUserWorkItem(callBack, cts.Token);
            Console.WriteLine("按下回车键取消");
            Console.Read();
            cts.Cancel();
            Console.ReadKey();
        }

        private static void callBack(object state)
        {
            CancellationToken cancellationToken = (CancellationToken)state;
            Console.WriteLine("开始计数");
            Count(cancellationToken, 100);
        }

        private static void Count(CancellationToken token, int countno)
        {
            for (int i = 0; i < countno; i  )
            {
                if (token.IsCancellationRequested)
                {
                    Console.WriteLine("计数取消");
                    return;
                }
                Console.WriteLine("计数为:"   i);
                Thread.Sleep(300);
            }
            Console.WriteLine("计数完成");
        }
    }
}

线程同步

线程同步技术是指在多线程程序中,为了保证后者线程,只有等待前者线程完成之后才能继续执行。这就好比在生活中排队买票,在前面的人没买到票之前,后面的人必须等待

多线程程序中存在的隐患

多线程应用程序可以提高程序的性能,并提供更好的用户体验。然而当我们创建了多个线程后,它们就有可能同时去访问某一个共享资源,这将损坏资源中保存的数据。在这种情况下,我们需要使用线程同步技术,确保某一时刻只有一个线程在操作共享资源

举例来说,火车售票系统程序员云熙多人同时购票,因此该系统肯定采用了多线程技术。但由于系统中有多个线程在对统一资源进行操作,我们必须确保只有在其他线程执行结束后,新的线程才开始执行。这样可以避免多位顾客买到同一张火车票。此时需要使用的就是线程同步技术

为了更好地说明线程同步的必要性,下面给出模拟火车票购票系统的代码:

代码语言:javascript复制
using System;
using System.Threading;

namespace Demo
{
    class Program
    {
        static int tickets = 100;
        static void Main(string[] args)
        {
            Thread thread1 = new Thread(SaleTicket1);
            Thread thread2 = new Thread(SaleTicket2);
            thread1.Start();
            thread2.Start();
            thread1.Join();
            thread2.Join();
        }

        public static void SaleTicket1()
        {
            while (tickets > 0)
            {
                Console.WriteLine("线程1正在出票:"   tickets--);
            }
        }

        public static void SaleTicket2()
        {
            while (tickets > 0)
            {
                Console.WriteLine("线程2正在出票:"   tickets--);
            }
        }
    }
}

主线程创建了两个出票线程,然后调用了Start函数让两个线程开始运行,即进行买票。我们执行结果可以看出两个线程交替运行,但是出售的火车票好吗并不是连续的,说明以上应用程序的售票过程是不正确的,这也就是多线程程序所存在的问题,因为两个线程访问了同一个全局变量——tickets

为了避免这种情况的发生,我们需要对多个线程进行同步处理,保证在同一时间内只有一个线程访问共享资源,以及保证前面的线程售票完成后,后面的线程才会访问资源

使用监视器对象实现线程同步

监视器对象(Monitor)能确保线程拥有对共享资源的互斥访问权,C# 通过 lock 关键字来提供简化的语法。下面我们使用线程同步技术来修改前面的代码:

代码语言:javascript复制
using System;
using System.Threading;

namespace Demo
{
    class Program
    {
        static int tickets = 100;
        static object globalObj = new object();
        static void Main(string[] args)
        {
            Thread thread1 = new Thread(SaleTicket1);
            Thread thread2 = new Thread(SaleTicket2);
            thread1.Start();
            thread2.Start();
            thread1.Join();
            thread2.Join();
        }

        public static void SaleTicket1()
        {
            while (tickets > 0)
            {
                try
                {
                    Monitor.Enter(globalObj);
                    Thread.Sleep(1);
                    Console.WriteLine("线程1正在出票:"   tickets--);
                }
                finally
                {
                    Monitor.Exit(globalObj);
                }
            }
        }

        public static void SaleTicket2()
        {
            while (tickets > 0)
            {
                try
                {
                    Monitor.Enter(globalObj);
                    Thread.Sleep(1);
                    Console.WriteLine("线程2正在出票:"   tickets--);
                }
                finally
                {
                    Monitor.Exit(globalObj);
                }
            }
        }
    }
}

注意,使用 Monitor 锁定的对象需要为引用类型,而不能为值类型。因为在将值类型变量传递给 Enter 时,它将被先装箱为一个单独的对象,之后再传递给 Enter 方法;而在将变量传递给 Exit 方法时,也会创建一个单独的引用对象。此时,传递给 Enter 方法的对象和传递给 Exit 方法的对象不同,Monitor 会引发 SynchronizationLockException 异常

线程同步技术存在的问题

在设计应用程序时,应该尽量避免使用线程同步,因为它会引起一些问题

  • 它的使用比较繁琐。我们要用额外的代码把多个线程同时访问的数据包围起来,并获取和释放线程的同步锁。如果在一个代码块忘记获取锁,就有可能造成数据损坏
  • 使用线程同步会影响程序性能。因为获取和释放锁是需要时间的;并且在决定哪个线程先获取锁的时候,CPU 也必须进行协调。这些额外的工作都会对性能造成影响
  • 线程同步每次只允许一个线程访问资源,这会导致线程阻塞。继而,系统会创建更多的线程,CPU 也就要负担更繁重的调度工作。这个过程会对性能造成影响

所以在实际开发过程中,要尽量避免使用线程同步技术,避免使用共享数据

0 人点赞