zoukankan      html  css  js  c++  java
  • 第13章 C#中的多线程

    13多线程

    13.1 线程概述

    计算机的操作系统多采用多任务和分时设计。多任务是指在一个操作系统中开以同时运行多个程序。例如,可以在使用QQ聊天的同时听音乐,即有多个独立的任务,每个任务对应一个进程,每个进程也可产生多个线程。

    13.1.1 进程

    认识进程先从程序开始,程序(Program)是对数据描述与操作的代码的集合,如Office中的Word,影音风暴等应用程序。

    进程(Process)是程序的一次动态执行过程,它对应了从代码加载、执行至执行完毕的一个完整过程,这个过程也是进程本身从产生、发展直至消亡的过程。操作系统同时管理一个计算机系统中的多个进程,让计算机系统中的多个进程轮流使用CPU资源,或者共享操作系统的其它资源。

    进程的特定是:

    • 进程是系统运行程序的基本单位
    • 每一个进程都有自己独立的一块内存空间、一组系统资源。
    • 每一个进程的内部数据和状态都是完全独立的。

    在操作系统中可以有多个进程,这些进程包括系统进程(由操作系统内部建立的进程)和用户进程(由用户程序建立的进程)。可以从Windows任务管理器中查看已启动的进程。图13-1中标注了已启动的Office Word进程。

    13-1 Windows任务管理器中的Office World进程

    13.1.2 线程

    线程是进程中执行运算的最小单位,可完成一个独立的顺序控制流程。每个进程中,必须至少建立一个线程(这个线程称为主线程)来作为这个程序运行的入口点。如果在一个进程中同时运行了多个线程,用来完成不同的工作,则称之为"多线程"。在操作系统将进程分成多个线程后,实际上每个任务是一个线程,多个线程共享相同的地址空间并且共同分享同一个进程,这些线程可以在操作系统的管理下并发执行。从而大大提高了程序的运行效率。虽然线程的执行看似是多个线程同时执行,但实际上并非如此。由于单CPU的计算机中,CPU同时只能执行一条指令,因此,在仅有一个CPU的计算机上不可能同时执行多个任务。而操作系统为了能提高程序的运行效率,将CPU的执行时间分成多个时间片,分配给不同的线程,当一个时间片执行完毕后,该线程就可能让出CPU使用权限交付给下一个时间片的其它线程执行。当然有可能相邻的时间片分配给同一线程。之所以表面上看是多个线程同时执行,是因为不同线程之间切换的时间非常短,也许仅仅是几毫秒,对普通人来说是难以感知的,这样就看似多个线程在同时执行了。

    为了更好地了解线程,下面举一个通俗的例子。张平是某互联网公司的开发人员,有时工作比较单一,仅是开发项目,写代码,这就好像程序只执行一个线程。在单线程环境中,每个程序执行的方式是只有一个处理顺序。但对开发人员来说,这只是一种理想的状态,有时还需要兼顾其它工作,如修改Bug,参与技术交流、编写项目文档,和其它部门同时沟通项目需求等。项目经理一般会希望张平一天内多项工作都会有进展,但是在同一时间,张平只能做一个项目工作。这就好像程序开启了多个线程,可以处理多个不同任务,但是CPU在同一时刻,只能执行该进程的一个线程。

    13.1.3 多线程的好处

    多线程作为一种多任务并发的工作方式,有着广泛的应用,合理地使用线程,将减少开发和维护的成本,甚至可以改善复杂应用程序的性能。使用多线的优势如下。

    • 充分利用CPU的资源:执行单线程程序时,若程序发生阻塞,CPU可能会处于空闲状态,这将造成计算机资源浪费,而使用多线程可以在某个线程处理休眠或阻塞状态时运行其它线程,这样,大大提高了资源利用率。
    • 简化编程模型:一个既长又复杂的进程可以考虑分为多个线程,成为几个独立的运行部分,如使用时、分、秒来描述当前时间,如果写成单线程程序可能需要多循环判断,而如果使用多线程,时、分、秒各使用一个线程控制,每个线程仅需实现简单的流程,简化了程序逻辑,这样更有助于开发人员对程序的理解和维护。
    • 带来良好的用户体验:由于多个线程可以交替执行,减少或避免了因程序阻塞或意外情况造成的响应过慢现象,降低了用户等待的几率。

    13.2 C#中实现多线程

    .NET中,Thread类将线程所必需的功能做了封装,位于System.Threading命名空间。

    13.2.1 主线程

    在程序启动时,一个线程立刻运行,该线程通常称为程序的主线程。程序的Main()方法是主线程的入口。每个进程都至少有一个主线程。它是程序开始时就执行的。主线程的重要性体现在以下两方面。

    • 它是产生其它子线程的线程
    • 通常它必须最后完成执行,因为它执行各种关闭动作。

    尽管主线程在程序启动时自动创建,但它可以由一个Thread对象控制。下面的实例1显示如何引用主线程。

    示例1

         static void Main(string[] args)
        {
            //
    判断当前线程是否已经命名
            if (Thread.CurrentThread.Name == null)
            {
                Thread.CurrentThread.Name = "MainThread";
            }
            Console.WriteLine("
    当前线程的名称为:"+Thread.CurrentThread.Name);
        }

    在实例1中,Thread类的CurrentThread静态属性可以获得当前主线程对象,线程对象的Name属性表示线程的名称,此属性默认值为null,且只可赋值一次。如果第二次赋值,则会引发异常,故在实例1中先判断线程对象是否已经命名。程序运行结果如图13-2所示。

     

    13-2 显示当前线程名称

    开发中,用户编写的线程一般是指除了主线程之外的其它线程。使用一个线程的过程可以分为以下三个步骤。

    1)定义一个线程对象,同时指明这个线程所要执行的代码,即期望完成的功能。

    2)启动线程。

    3)终止线程。

    13.2.2 创建线程

    下面来编写代码,创建两个线程,在线程中输出1~100的整数。

    实例2

    static void Main(string[] args)
    {
    //
    创建第一个线程对象
    Thread thread1 = new Thread(DoWork);
    thread1.Name = "myThread1";
    //
    创建第二个线程对象
    Thread thread2 = new Thread(DoWork);
    thread2.Name = "myThread2";
    //
    启动线程
    thread1.Start();
    thread2.Start();
    }
    //
    线程要调用的方法
    static void DoWork()
    {
    for(int i=1;i<=100;i++)
    {
    Console.WriteLine(Thread.CurrentThread.Name+":"+i);
    }
    }

    实例2中,通过Thread类的构造函数创建线程对象,Thread的构造函数形式的如下:

    public Thread(ThreadStart start);

    其参数start的类型ThreadStart是一个委托。表示要执行的方法。

    Thread对象的Start()方法用来启动线程。程序运行结果如图13-3所示。

     

    13-3 创建线程并启动

    通过图13-3的结果可以看出,两个线程对象调用Start()方法后,各自都会输出100以内的整数,互不影响,并行执行。在.NET中每个线程都有自己独立的内存栈,所以每个线程的本地变量都相互独立。但由于CPU在一个时间点只能执行一个线程,因此多个线程是交替执行的,获得CPU时间片的线程即刻执行,当前时间片执行完毕后,CPU就会执行获得下一个时间片的线程。分配的时间片长度不是完全一致的,可多可少,因此每次运行的结果有所不同,总之,是轮换交替执行的。

    13.3 线程的状态

    任何线程一般都具有五种状态,即创建、就绪、运行、阻塞、死亡状态。线程状态的转移与方法之间的关系如图13-4所示。

     

    13-4 线程状态的转移与方法之间的关系

    • 创建状态
      在程序中创建一个线程对象后,新的线程对象就处于创建状态,此时,他已经获取了相应的资源,但还没有处于可运行的状态,这时可以设置
      Thread对象的属性,如线程名称、优先级等。
    • 就绪状态
      线程创建之后,就可以通过调用
      Start()方法启动线程,即进入就绪状态。此时,线程将进入线程队列排队,等待CPU资源,这表明它已经具备了运行条件,在未获得CPU资源时,仍不能真正执行。举例来说,去医院看病,某主任的专家号每天只有20个,挂上号的患者还需在分诊处等待叫号。这里每个挂到专家号的患者可以看成一个就绪状态的线程。
    • 运行状态
      当就绪状态的线程获得
      CPU资源时,即可转入运行状态。对只有一个CPU的计算机而言,任何时刻只能有一个处于运行状态的线程占用CPU,即获得CPU资源。延续上面医院看病的例子,被叫到的患者才能真正就诊,而每个主任专家在一个时刻只能为一个患者看病。
    • 阻塞状态
      一个正在运行的线程因某种原因不能继续运行时,进入阻塞状态。阻塞状态是一种"不可运行"的状态,而处于这种状态的线程在得到一个特定的事件之后会转回可运行状态。举例来说,轮到小张看病了,医生为查明原因要求他去做个化验,医生得到化验结果后才能继续诊断,如果把医生给小张看病看作一个线程,则该线程此时即处于阻塞状态。
      可能使线程暂停执行的条件如下。
      • 由于线程优先级比较低,因此它不能获得CPU资源。
      • 使用Sleep()方法使线程休眠。
      • 通过调用Wait()方法,使线程等待。
      • 通过调用Yield()方法,线程显示出让CPU控制权。
      • 线程由于等待一个文件,I/O事件被阻塞。
    • 死亡状态
      一个线程运行完毕,线程则进入死亡状态。处于死亡状态的线程不具有继续运行的能力。

    13.4 线程的调度
    在单CPU的计算机中,一个时刻只有一个线程运行,所谓多线程的并发运行,其实是指从宏观上看,各个线程轮流获得CPU资源的使用权,分别执行各自的任务。.NET Framework可以对线程进行调度。线程调度是指按照特定机制为多个线程分配CPU的使用权。
    13.4.1
    线程的优先级
    当同一时刻有一个或多个线程处于运行状态时,它们需要排队等待CPU资源,每个线程会自动获得一个线程的优先级(Priority),优先级的高低反映线程的重要或紧急程度。那么此刻,一般情况下优先级高的线程获得CPU资源的概率较大,但这个结果不是绝对的,线程优先级调度是一个复杂的过程。
    Thread
    对象可以通过Priority属性(枚举类型)设置线程的优先级,优先级从低到高的五个取值为LowestBelowNormalNormalAboveNormalHighest
    我们对实例2中的代码进行修改,分别设置连个线程的优先级,如实例3所示。
    实例3
    static void Main(string[] args)
    {
    //
    创建第一个线程对象
    Thread thread1 = new Thread(DoWork);
    thread1.Name = "myThread1";
    //
    创建第二个线程对象
    Thread thread2 = new Thread(DoWork);
    thread2.Name = "myThread2";
    //
    设置线程的优先级
    thread1.Priority = ThreadPriority.Highest;
    thread2.Priority = ThreadPriority.Lowest;
    //
    启动线程
    thread1.Start();
    thread2.Start();
    }
    //
    线程要调用的方法
    static void DoWork()
    {
    for(int i=1;i<=100;i++)
    {
    Console.WriteLine(Thread.CurrentThread.Name+":"+i);
    }
    }
    运行结果如图13-5所示。

     

    13-5 线程的优先级

    从图13-5中看出,优先级高的thread1对象优先执行完成。
    13.4.2 线程的休眠
    在程序中允许一个线程进行暂时休眠,直接使用
    Thread.Sleep()方法即可实现线程的休眠。Sleep()方法的定义语法如下。
    public static void Sleep(int millisecondsTimeout)
    参数表示休眠时长,单位为毫秒。线程由运行中的状态进入不可运行状态,睡眠时间过后线程会再次进入可运行状态。
    实例
    4模拟主线程休眠五秒后开始执行。
    实例
    4
    class Program
    {
    static void Main(string[] args)
    {
    Console.WriteLine("Wait");
    WaitBySec(5); //
    让主线程等待5秒再执行
    Console.WriteLine("Start");
    }

    public static void WaitBySec(int s)
    {
    for(int i=0;i<s;i++)
    {
    Console.WriteLine(i+1+"
    ");
    Thread.Sleep(1000); //
    睡眠1
    }
    }
    }
    运行结果如图13-6所示。

     

    13-6 线程的休眠

    13.4.3 线程的强制运行

    Join()方法使当前线程暂停执行,等待调用该方法的线程结束后再继续执行本线程。

    下面通过实例来具体看一下Join()方法的应用。实例5为使用Join()方法阻塞线程的案例。

    实例5

    1

    2

    3

    4

    5

    6

    7

    8

    9

    10

    11

    12

    13

    14

    15

    16

    17

    18

    19

    20

    21

    22

    23

    24

    25

    26

    27

    class Program

    {

    static void Main(string[] args)

    {

    Console.WriteLine("*********线程强制执行**********");

    //创建子线程并启动

    Thread temp = new Thread(DoWork);

    temp.Name = "MyThread1";

    temp.Start();

    for(int i=0;i<20;i++)

    {

    if (i == 5)

    temp.Join(); //阻塞主线程,子线程强制执行

    Thread.Sleep(100);

    Console.WriteLine("主线程运行:"+i);

    }

    Console.ReadKey();

    }

    static void DoWork()

    {

    for(int i=1;i<=10;i++)

    {

    Thread.Sleep(100); //增加线程交替执行的几率

    Console.WriteLine(Thread.CurrentThread.Name+":"+i);

    }

    }

    }

    在示例5中,主线程的i的值为5时,子线程调用Join()方法,阻塞主线程,子线程强制执行,直到子线程运行完毕后,主线程才能继续执行。运行结果如图13-7所示。

     

    13-7 线程的强制执行

    13.4.4 线程的礼让

    Yield()方法定义的语法如下。

    public static bool Yield();

    Yield()方法可暂停当前线程执行,允许其它具有相同优先级的线程获得运行机会,该线程仍处于就绪状态,不转为阻塞状态,此时,系统选择其它相同或更高优先级线程执行。若无其它相同或更高优先级线程,则该线程继续执行。返回值为bool类型,如果操作系统转而执行另一个线程,则为 true;否则为 false。示例6实现了两个线程之间的礼让。

    注意

    使用Yield()的线程礼让只是提供一种可能,但是不能保证一定会礼让,因为礼让的线程处于就绪状态,还有可能被线程调度程序再次选中。

    示例6

    1

    2

    3

    4

    5

    6

    7

    8

    9

    10

    11

    12

    13

    14

    15

    16

    17

    18

    19

    20

    21

    22

    23

    24

    25

    class Program

    {

    static void Main(string[] args)

    {

    Thread thA = new Thread(DoWork);

    thA.Name = "线程A";

    Thread thB = new Thread(DoWork);

    thB.Name = "线程B";

    thA.Start();

    thB.Start();

    Console.ReadKey();

    }

    static void DoWork()

    {

    for(int i=0;i<5;i++)

    {

    Console.WriteLine(Thread.CurrentThread.Name+"正在运行:"+i);

    if (i==3)

    {

    Console.Write("线程礼让:");

    Thread.Yield();

    }

    }

    }

    }

    运行结果如图13-8所示

     

    13-8 线程的礼让

    从程序的运行结果中可以放发现,每当线程满足条件(i==3)时,建议当前线程暂停,而让其它线程先执行,当然,这仅是提供一种可能。

    13.5 线程的同步

    13.5.1 多线程共享数据引发的问题

    前面学习的线程都是独立的,而且异步执行,也就是说每个线程都包含了运行时所需要的数据或方法,而不需要外部资源或方法,也不必关心其它线程的状态和行为。但是经常有一些同时运行的线程需要共享数据,此时就需要考虑其它线程的状态和行为,否则不能保证程序运行结果的正确性。

    举个例子来说,我们都熟悉每年春运抢票的场景。以前需要亲自到火车站或者售票点排队买票,火车站每个车次会定期定量发放车票,先到先得。现在互联网越来越发达,可以网上买票了,这样又多了一个更加方便的购票渠道。现在,我们使用多线程来模拟多人买票的过程。每个人抢到票的机会均等,这样,可以把每一个看作是一个线程,购票过程是线程的执行体,而每售出一张票,总票数就会减少,因此注意,预发售的火车票总数是多线程所共同操作的数据。假定现在有三个人抢十张票,实现思路如下。

    1)定义类Site模拟售票网站。发放固定车次的车票,这里为简化过程,设定预出售的车票总共十张,定义变量Count记录剩余票数,变量Num记录当前售出第几张票。

    2实现售票方法SaleTicket()。网站将持续提供售票服务,因此,这里使用到循环语句,Count作为循环变量。在循环体中,当还有余票时,购票过程分为以下两步。

    第一步,修改数据,指当前售票序号(Num)以及剩余票数(Count)

    第二步,显示售票信息。

    在两步之间,为模拟网络延迟,使用Sleep()方法设置线程休眠500毫秒。

    3)定义测试类模拟多人抢票。创建三个线程,指定线程名称,并启动线程。

    代码如示例7所示。

    示例7

    1

    2

    3

    4

    5

    6

    7

    8

    9

    10

    11

    12

    13

    14

    15

    16

    17

    18

    19

    20

    21

    22

    23

    24

    25

    26

    27

    28

    29

    30

    31

    32

    33

    34

    35

    36

    public class Site

    {

    int Count = 10; //记录剩余票数

    int Num = 0; //记录买到第几张票

    //售票方法

    public void SaleTicket()

    {

    while(true)

    {

    //没有余票时跳出循环

    if (Count <= 0)

    break;

    //第一步:修改数据

    Num++;

    Count--;

    Thread.Sleep(500); //模拟网络延时

    //第二步:显示信息

    Console.WriteLine("{0}抢到第{1}票,剩余{2}票!",

    Thread.CurrentThread.Name,Num,Count);

    }

    }

    }

    //测试类Main()方法

    static void Main(string[] args)

    {

    Site site = new Site();

    Thread person1 = new Thread(site.SaleTicket);

    person1.Name = "王小毛";

    Thread person2 = new Thread(site.SaleTicket);

    person2.Name = "抢票代理";

    Thread person3 = new Thread(site.SaleTicket);

    person3.Name = "黄牛党";

    person1.Start();

    person2.Start();

    person3.Start();

    }

    示例7中,Main()方法中创建三个线程模拟三人开始抢票,并且启动线程。运行结果如图13-9所示。

     

    13-9 多线程模拟网络购票

    从图13-8中发现,最终显示结果存在以下问题。

    • 不是从第一张票开始。
    • 存在多人抢到一张票的情况。
    • 有些票号没有被抢到。

    这是由于多线程并发执行操作同一共享资源时,将带来数据不安全问题。例如,在上述案例中。三个人共同抢票,各自执行完第一步修改数据,此时等待网络延时,再执行第二步显示信息时,由于前面修改了三次数据后Count值为7Num值为3,最终显示三个人都抢到了第三张票。这当然仅仅是一种情况。

    要解决此类问题,就需要保证一个人在抢票过程未结束前,不允许其他人同时抢票。这在开发中,就需要使用线程同步。

    注意

    示例7中提出的问题仅在多线程共享统一资源时产生,如三人共抢十张票;反之,在不存在资源共享时无需考虑此类问题。

    13.5.2 线程同步的实现

    当两个或多个线程需要访问同一资源时,需要以某种顺序来确保该资源某一时刻只能被一个线程使用,这就称为线程同步。

    C#中,我们可以通过lock语句实现线程同步。

    资料

    C#中实现线程的同步有几种方法:lockMutexMonitorSemaphoreInterlockedReaderWriterLock等。同步策略也可以分为同步上下文、同步代码区、手动同步几种方式。大家可以查阅MSDN自行学习。

    lock 关键字将语句块标记为临界区,方法是获取给定对象的互斥锁,执行语句,然后释放该锁。lock 确保当一个线程位于代码的临界区时,另一个线程不进入临界区。如果其他线程试图进入锁定的代码,则它将一直等待(即被阻止),直到该对象被释放。其语法如下:

    lock(locker)
    {
    //
    需要同步的代码
    }

    提供给 lock 关键字的参数locker必须为基于引用类型的对象,该对象用来定义锁的范围,可以是任意类实例。

    针对示例7的情况,我们用lock语句完成线程同步。如实例8所示

    示例8

    public class Site
    {
    int Count = 10; //
    记录剩余票数
    int Num = 0; //
    记录买到第几张票
    //
    定义锁定对象
    private object locker = new object();

    public void SaleTicket()
    {
    while(true)
    {
    lock (locker)
    {
    //
    没有余票时跳出循环
    if (Count <= 0)
    break;
    //
    第一步:修改数据
    Num++;
    Count--;
    Thread.Sleep(500); //
    模拟网络延时
    //
    第二步:显示信息
    Console.WriteLine("{0}
    抢到第{1}票,剩余{2}票!",
    Thread.CurrentThread.Name, Num, Count);
    }
    }
    }

    }

    当同步对共享资源的线程访问时,请锁定专用对象实例(例如,private object locker = new object();)或另一个不太可能被代码无关部分用作 lock 对象的实例。避免对不同的共享资源使用相同的 lock 对象实例,因为这可能导致死锁或锁争用。具体而言,避免将以下对象用作 lock 对象:

    • this(调用方可能将其用作 lock)。
    • Type 实例(可以通过 typeof 运算符或反射获取)。
    • 字符串实例,包括字符串文本。

    运行结果如图13-10所示。

     

    13-10 使用线程同步的网络购票

    13.5.3 线程安全的类型

    若所在的进程中有多个线程在同时运行,而这些线程同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。

    一个类在被多个线程访问时,不管运行时环境执行这些线程有什么样的时序安排,它必须是以固定的、一致的顺序执行。这样的类型称为线程安全的类型。

    ArrayList集合为例,在向ArrayList对象添加一个元素的时候,由两步来完成:

    1)将索引值加1,即为集合扩容,确保可装入新元素。

    2)在新增位置存放数据。

    Add()方法是非线程安全的,如有线程A和线程B向同一个ArrayList对象中添加元素,两个线程同时获得Count值为5,之后同时执行完加1操作再赋值给Count,两个线程为同一个位置元素赋值,后一个覆盖前一个,引发数据不安全问题。说明ArrayList是非线程安全的类型。

    资料

    ArrayList类可以通过Synchronized()方法用来得到一个线程安全的ArrayList对象,语法如下:

    ArrayList arr = ArrayList.Synchronized(new ArrayList());

    另外Hashtable也有类似的方法。

    .Net 4,新增System.Collections.Concurrent 命名空间中提供多个线程安全集合类,这些类提供了很多有用的方法用于访问集合中的元素,从而可以避免使用传统的锁(lock)机制等方式来处理并发访问集合。因此当有多个线程并发访问集合时,应首先考虑使用这些类代替 System.Collections System.Collections.Generic 命名空间中的对应类型。具体如下。

    1.ConcurrentQueue

    表示线程安全的先进先出 (FIFO) 集合。Enqueue(T) 方法用于将对象添加到 ConcurrentQueue 的结尾处。

    2.ConcurrentStack

    表示线程安全的后进先出 (LIFO) 集合。 ConcurrentStack 提供了几个主要操作:

    • Push 在顶部插入一个元素ConcurrentStack
    • TryPop 从顶部移除一个元素ConcurrentStack,或返回false如果不能删除该项。
    • TryPeek 返回位于顶部的元素ConcurrentStack但不会删除从ConcurrentStack
    • TryPopRangePushRange方法提供了有效推送和弹出的单个操作中的多个元素。

    3.ConcurrentBag

    表示对象的线程安全的无序集合。 Add(T)方法用于将对象添加到 ConcurrentBag 中。

    4.ConcurrentDictionary

    Dictionary类似,表示可由多个线程同时访问的键/值对的线程安全集合。 常用方法如下:

    • TryAdd(TKey, TValue) :尝试将指定的键和值添加到 ConcurrentDictionary 中。
    • TryGetValue(TKey, TValue) :尝试从 ConcurrentDictionary 获取与指定的键关联的值。
    • TryRemove(TKey, TValue) :尝试从 ConcurrentDictionary 中移除并返回具有指定键的值。
    • TryUpdate(TKey, TValue, TValue) :如果具有 key 的现有值等于 comparisonValue,则将与 key 关联的值更新为 newValue

    通过上述提供的安全类,我们可以方便的并发访问集合中的元素,而不需要以前的Synchronized方法或者lock(SyncRoot)等处理方式。

    在多线程操作中,需要选择线程安全的类型或通过同步操作避免多个线程共享资源时引发的问题,但线程的同步也会损失性能,因此,为达到安全性和效率的平衡,可根据实际场景来选择合适的类型。

  • 相关阅读:
    Ajax基础:3.Json
    Head First Design Patterns State Pattern
    Head First Design Patterns Template Method Pattern
    Articles For CSS Related
    Head First Design Patterns Decorator Pattern
    代码审查工具
    How To Be More Active In A Group
    Head First Design Patterns Factory Method Pattern
    Head First Design Patterns Composite Pattern
    Tech Articles
  • 原文地址:https://www.cnblogs.com/mrfang/p/10880587.html
Copyright © 2011-2022 走看看