多线程总结

1、线程委托
委托定义:委托是把方法(实例方法或静态方法)当做参数传递,实现事件处理。
创建线程时用到的委托:ThreadStart和ParameterizedThreadStart。
Thread t1 = new Thread(new ThreadStart(Menthod1));
ThreadStart委托不带参数,其定义:public delegate void ThreadStart();
Thread t2 = new Thread(new ParameterizedThreadStart(Menthod2));
ParameterizedThreadStart带一个object类型参数,其定义:public delegate void ParameterizedThreadStart(object obj);
两者都不返回值。

2、sleep和wait的区别
前者属于Thead的方法。它在使用时,线程不会释放对象的使用权。到期后自动恢复运行。
后者属于Object类。它会释放对象锁。需要另一个线程调用notify唤醒。

3、关于线程锁
请多使用lock,少用Mutex
如果你一定要使用锁定,请尽量不要使用内核模块的锁定机制,比如.net的Mutex,Semaphore,AutoResetEvent,ManuResetEvent,使用这样的机制涉及到了系统在用户模式和内核模式间的切换,所以性能差很多,但是他们的优点是可以跨进程同步线程,所以应该清楚的了解到他们的不同和适用范围。


另外,从原理上讲,lock和Syncronized Attribute(即[MethodImpl(MethodImplOptions.Synchronized)])都是用Moniter.Enter实现的。
4、Join方法
当在一个线程里调用此方法时表示本线程进入阻塞,直到另一个线程执行完毕后再继续执行。
如:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;

namespace ThreadTest
{
    public class threadTest8
    {
        public static void Main1()
        {
            Thread t1 = new Thread(new ThreadStart(Menthod1));
            t1.Start();
            //t1.Join();

            Console.WriteLine("主线程的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("---------主线程-----------");
            Console.ReadLine();
        }

        static void Menthod1()
        {
            Console.WriteLine("线程1的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Thread.Sleep(4000);
            Thread t2 = new Thread(new ParameterizedThreadStart(Menthod2));
            t2.Start("线程2参数");
            t2.Join();
            Console.WriteLine("-----------线程1---------");
        }

        static void Menthod2(object obj)
        {

            Console.WriteLine("线程2的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("obj:{0}", obj);
            Thread.Sleep(4000);
            Console.WriteLine("-----------线程2---------");
        }
    }
}

上面的代码在主线程执行时异步启用了线程1,线程1内部启动了线程2,并且线程1使用了Join方法,必须等线程2执行完毕了线程1才能继续往下执行,结果如下:

注:
主线程需要知道子线程什么时候执行完成,可以使用Thread.ThreadState枚举来判断。
当线程的ThreadState==ThreadState.Stop时,一般就说明线程完成了工作,这时结果就可用了,如果不是这个状态,就继续执行别的工作,或者等待一会,然后再尝试.倘若需要等有多个子线程需的返回,并且需要用他们的结果来进行进异步计算,那就叫做线程同步了。

 5、利用异步委托实现自定义参数个数,并且返回数据(BeginInvoke、EndInvoke、IAsyncResult、AsyncCallback)
代码:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Web.Script.Serialization;

namespace ThreadTest
{
    public class threadTest10
    {
        public static void Main10()
        {
            Console.WriteLine("主程序开始--------------------");
            int threadId;
            AsyncDemo ad = new AsyncDemo(); //创建一个实例,并把该实例方法丢给委托
            AsyncMethodCaller caller = new AsyncMethodCaller(ad.TestMethod);

            ////测试无参的回调
            //AsyncCallback callback = new AsyncCallback(c => Console.WriteLine("回调方法。"));

            //创建一个模拟多参数场景
            ReqParams reqObj = new ReqParams { paramItem1 = 1, paramItem2 = "张三" };
            IAsyncResult result = caller.BeginInvoke(3000, out threadId, CallbackMethod, reqObj);
            Console.WriteLine("主线程线程 {0} 正在运行.", Thread.CurrentThread.ManagedThreadId);

            ////会阻塞线程,直到后台线程执行完毕之后,才会往下执行
            //result.AsyncWaitHandle.WaitOne();

            Console.WriteLine("主程序在做一些事情。。。");
            //获取异步执行的结果
            string returnValue = caller.EndInvoke(out threadId, result);
            Console.WriteLine("异步执行的结果:" + returnValue);
            //释放资源
            result.AsyncWaitHandle.Close();
            Console.WriteLine("主程序结束--------------------");
            Console.Read();
        }

        static void CallbackMethod(IAsyncResult ar){
            Console.WriteLine("进入回调方法。");
            //回调函数中获取参数
            ReqParams reqObj = (ReqParams)ar.AsyncState;
            Console.WriteLine("回调方法中获取到参数:" + new JavaScriptSerializer().Serialize(reqObj));
            //一些其他操作。。。
            Console.WriteLine("回调方法执行完毕!");
        }
    }
    public class AsyncDemo
    {
        //供后台线程执行的方法
        public string TestMethod(int callDuration, out int threadId)
        {
            Console.WriteLine("异步执行的方法开始执行.");
            Thread.Sleep(callDuration);
            threadId = Thread.CurrentThread.ManagedThreadId;
            return String.Format("异步执行的方法返回数据: {0}.", callDuration.ToString());
        }
    }

    /// <summary>
    /// 模拟请求数据,在方法中传递
    /// </summary>
    public class ReqParams{
        public int paramItem1 { get; set; }
        public string paramItem2 { get; set; }
    }

    public delegate string AsyncMethodCaller(int callDuration, out int threadId);
}

 执行结果:

另一个示例,来自:https://blog.csdn.net/jh_wiki/article/details/52955927

//定义委托  
public delegate void Asyncdelegate(WebProxy objName);  
  
//异步调用完成时,执行回调方法  
private void CallbackMethod(IAsyncResult ar)  
{  
	Asyncdelegate dlgt = (Asyncdelegate)ar.AsyncState;  
	dlgt.EndInvoke(ar);  
}  
  
//异步调用Commit方法  
public virtual void Run()  
{  
	Asyncdelegate isgt = new Asyncdelegate(Commit);  
	IAsyncResult ar = isgt.BeginInvoke(null,new AsyncCallback(CallbackMethod),isgt);  
} 

 这个示例和上面有点不一样的地方在于,在回调方法里获取异步执行的结果。

注:传给委托的方法也是个实例方法,如:

//向APM接口提交数据  
//为什么要用WebProxy,因为.Net 4.0以下没有Host属性,无法设置标头来做DNS重连  
public virtual void Commit(WebProxy objName = null)  
{  
string ret = string.Empty;  
string ip = string.Empty;  
try  
{  
	HttpWebRequest request = (HttpWebRequest)WebRequest.Create("http://www.baidu.com");  
	request.Method = "POST";//  
	request.Timeout = 30000;  
	request.ContentType = "application/x-www-form-urlencoded";  
	request.ServicePoint.Expect100Continue = false;  
	if(objName != null)  
	{  
		request.Proxy = objName;  
	}  
	byte[] byteArray = Encoding.UTF8.GetBytes("&a=要发送的数据&b=要发送的数据");  
	request.ContentLength = byteArray.Length;  
	Stream dataStream = request.GetRequestStream();  
	dataStream.Write(byteArray,0,byteArray.Length);  
	dataStream.Close();  
	WebResponse response = request.GetResponse();  
	dataStream = request.GetResponseStream();  
	StreamReader reader = new StreamReader(dataStream);  
	string responseFromServer = reader.ReadToEnd();  
	reader.Close();  
	dataStream.Close();  
	response.Close();  
}  
catch(WebException ex)  
{  
	Debug.Error("提交Apm日志失败:"+ex.Status.ToString());  
	if(ex.Status.ToString().Equals("NameResolutionFailure"))//域名解释错误,重连一次,重新获取域名ip,用ip来拼接提交  
	{  
		ip = this.DnRetryGet("域名");  
		if(ip.Equals("0"))  
		{  
			return  
		}  
		WebProxy proxy = new WebProxy(ip,80);  
		this.Commit(proxy);  
	}  
}  
  
if(ret.Equals("1"))  
{  
	Debug.Log("提交Apm日志成功");  
}  
else  
{  
	Debug.Log("提交Apm日志失败:"+ret);  
}  
  
public virtual string DnsRetryGet(string doMain)  
{  
	  
}  

 6、异步委托补充
委托通常是以同步方式进行调用,即,在调用委托时,只有包装方法返回后该调用才会返回。要以异步方式调用委托,请调用 BeginInvoke 方法,这样会对该方法排队以在系统线程池的线程中运行。调用线程会立即返回,而不用等待该方法完成。这比较适合于 UI 程序,因为可以用它来启动耗时较长的作业,而不会使用户界面反应变慢。

调用 BeginInvoke 会使该方法在系统线程池的线程中运行,而不会阻塞 UI 线程以便其可执行其他操作。

BeginInvoke 将返回一个 IAsyncResult。这可以和委托的 EndInvoke 方法一起使用,以在该方法调用完毕后检索调用结果。

还有其他一些可用于在另外的线程上运行方法的技术,例如,直接使用线程池 API 或者创建自己的线程。然而,对于大多数用户界面应用程序而言,有异步委托调用就足够了。采用这种技术不仅编码容易,而且还可以避免创建并非必需的线程,因为可以利用线程池中的共享线程来提高应用程序的整体性能。

Windows 窗体中最重要的一条线程规则:除了极少数的例外情况,否则都不要在它的创建线程以外的线程中使用控件的任何成员。

由于以异步委托调用方式运行的代码在一个来自线程池的线程中运行,所以它不能访问任何 UI 元素。上述限制也适用于线程池中的线程和手动创建的辅助线程。

使用异步委托的好处:
①无需自己创建新线程,可以利用线程池中的共享线程来提高应用程序的整体性能。
②编码容易

7、辅助线程与UI控件通信方式
有关控件的限制看起来似乎对多线程编程非常不利。如果在辅助线程中运行的某个缓慢操作不对 UI 产生任何影响,用户如何知道它的进行情况呢?至少,用户如何知道工作何时完成或者是否出现错误?幸运的是,虽然此限制的存在会造成不便,但并非不可逾越。有多种方式可以从辅助线程获取消息,并将该消息传递给 UI 线程。理论上讲,可以使用低级的同步原理和池化技术来生成自己的机制,但幸运的是,因为有一个以 Control 类的 Invoke 方法形式存在的解决方案,所以不需要借助于如此低级的工作方式。

Invoke 方法是 Control 类中少数几个有文档记录的线程规则例外之一:它始终可以对来自任何线程的 Control 进行 Invoke 调用。Invoke 方法本身只是简单地携带委托以及可选的参数列表,并在 UI 线程中为您调用委托,而不考虑 Invoke 调用是由哪个线程发出的。实际上,为控件获取任何方法以在正确的线程上运行非常简单。但应该注意,只有在 UI 线程当前未受到阻塞时,这种机制才有效 — 调用只有在 UI 线程准备处理用户输入时才能通过。从不阻塞 UI 线程还有另一个好理由。Invoke 方法会进行测试以了解调用线程是否就是 UI 线程。如果是,它就直接调用委托。否则,它将安排线程切换,并在 UI 线程上调用委托。无论是哪种情况,委托所包装的方法都会在 UI 线程中运行,并且只有当该方法完成时,Invoke 才会返回。

Control 类也支持异步版本的 Invoke,它会立即返回并安排该方法以便在将来某一时间在 UI 线程上运行。这称为 BeginInvoke,它与异步委托调用很相似,与委托的明显区别在于,该调用以异步方式在线程池的某个线程上运行,然而在此处,它以异步方式在 UI 线程上运行。实际上,Control 的 Invoke、BeginInvoke 和 EndInvoke 方法,以及 InvokeRequired 属性都是 ISynchronizeInvoke 接口的成员。该接口可由任何需要控制其事件传递方式的类实现。

由于 BeginInvoke 不容易造成死锁,所以尽可能多用该方法;而少用 Invoke 方法。因为 Invoke 是同步的,所以它会阻塞辅助线程,直到 UI 线程可用。但是如果 UI 线程正在等待辅助线程执行某操作,情况会怎样呢?应用程序会死锁。BeginInvoke 从不等待 UI 线程,因而可以避免这种情况。

Control 类将公开一个称为 InvokeRequired 的属性。这是“只限 UI 线程”规则的另一个例外。它可从任何线程读取,如果调用线程是 UI 线程,则返回假,其他线程则返回真。

8、死锁
其中有两个或更多线程都被阻塞以等待对方返回。
死锁示例代码:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace ThreadTest
{
    /// <summary>
    /// 死锁例子
    /// </summary>
    public class deadLockTest
    {
    }

    public delegate void AsyncMethodCaller1();
    public class Foo
    {
        public void CallBar()
        {
            lock (this)
            {
                Console.WriteLine("Foo--CallBar");
                Bar myBar = new Bar();
                myBar.BarWork(this);

                
            }
        }

        // This will be called back on a worker thread
        public void FooWork()
        {
            lock (this)
            {
                // do some work
                Console.WriteLine("Foo--FooWork");
            }
        }
    }

    public class Bar
    {
        public void BarWork(Foo myFoo)
        {
            Console.WriteLine("Bar--BarWork");
            // Call Foo on different thread via delegate.
            AsyncMethodCaller1 mi = new AsyncMethodCaller1(myFoo.CallBar);

            IAsyncResult ar = mi.BeginInvoke(null,null);
            // do some work

            // Now wait for delegate call to complete (DEADLOCK!)
            mi.EndInvoke(ar);
            Console.WriteLine("Bar--执行完成");
            Console.ReadKey();
        }
    }
}

 解决方法:
①避免这种情况的最简单方法是,当持有一个对象锁时,不要等待跨线程调用完成。
②避免在锁语句中调用 Invoke 或 EndInvoke。

9、线程池有什么不足
没有提供方法控制加入线程池的线程:一旦加入线程池,我们没有办法挂起,终止这些线程,唯一可以做的就是等他自己执行。
1)不能为线程设置优先级
2)一个Process中只能有一个实例,它在各个AppDomain是共享的。ThreadPool只提供了静态方法,不仅我们自己添加进去的WorkItem使用这个Pool,而且.net framework中那些BeginXXX、EndXXX之类的方法都会使用此Pool。
3)所支持的Callback不能有返回值。WaitCallback只能带一个object类型的参数,没有任何返回值。
4)不适合用在长期执行某任务的场合。我们常常需要做一个Service来提供不间断的服务(除非服务器down掉),但是使用ThreadPool并不合适。

10、WaitHandle是什么,他和他的派生类怎么使用
WaitHandle是Mutex,Semaphore,EventWaitHandler,AutoResetEvent,ManualResetEvent共同的祖先,他们包装了用于同步的内核对象,也就是说是这些内核对象的托管版本。

11、同步事件:AutoResetEvent和ManualResetEvent
除了Join()来实现线程间的阻塞与激活,还有同步事件来进行处理;同步事件有两种:AutoResetEvent和 ManualResetEvent。
ManualResetEvent会给所有引用的线程都发送一个信号(多个线程可以共用一个ManualResetEvent,当ManualResetEvent调用Set()时,所有线程将被唤醒),而AutoResetEvent只会随机给其中一个发送信号(只能唤醒一个)
我们先用ManualResetEvent做个试验:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;

namespace ThreadTest
{
    /// <summary>
    /// 同步事件:AutoResetEvent和 ManualResetEvent
    /// </summary>
    public static class threadTest7
    {
        public static ManualResetEvent muilReset = new ManualResetEvent(false);
        public static void Main7()
        {

            Thread t1 = new Thread(Menthod1);
            t1.Start();
            Thread t2 = new Thread(Menthod2);
            t2.Start("params");
            Thread t3 = new Thread(Menthod3);
            t3.Start();
            muilReset.WaitOne();

            Console.WriteLine("主线程的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("---------主线程-----------");
            Console.ReadLine();
        }

        public static void Menthod1()
        {
            muilReset.WaitOne();
            Console.WriteLine("线程1的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("---------线程1-----------");
        }

        public static void Menthod2(object obj)
        {
            muilReset.WaitOne();
            Console.WriteLine("线程2的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("obj:{0}", obj);
            Console.WriteLine("---------线程2-----------");
        }

        public static void Menthod3()
        {
            Thread.Sleep(3000);
            Console.WriteLine("线程3的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("激活线程...");
            Console.WriteLine("----------线程3----------");
            muilReset.Set();
        }
    }
}

运行结果:

可以看到,我们将主线程、线程1、线程2阻塞,使用线程3在3秒钟之后激活了全部线程!但是被激活的主线程、线程1和线程2的执行顺序却是乱的。
下面我们再来试下AutoResetEvent:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;

namespace ThreadTest
{
    /// <summary>
    /// 同步事件:AutoResetEvent和 ManualResetEvent
    /// </summary>
    public static class threadTest11
    {
        //public static ManualResetEvent muilReset = new ManualResetEvent(false);
        public static AutoResetEvent autoReset = new AutoResetEvent(false);
        public static void Main11()
        {

            Thread t1 = new Thread(Menthod1);
            t1.Start();
            Thread t2 = new Thread(Menthod2);
            t2.Start("params");
            Thread t3 = new Thread(Menthod3);
            t3.Start();
            autoReset.WaitOne();

            Console.WriteLine("主线程的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("----------主线程----------");
            Console.ReadLine();
        }

        public static void Menthod1()
        {
            autoReset.WaitOne();
            Console.WriteLine("线程1的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("---------线程1-----------");
        }

        public static void Menthod2(object obj)
        {
            autoReset.WaitOne();
            Console.WriteLine("线程2的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("obj:{0}", obj);
            Console.WriteLine("----------线程2----------");
        }

        public static void Menthod3()
        {
            Thread.Sleep(1000);
            Console.WriteLine("线程3的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("激活线程...");
            Console.WriteLine("--------线程3------------");
            autoReset.Set();
        }
    }
}

 运行结果:

再次运行:

可以看到,AutoResetEvent只是随机激活一个线程。

上面的两个例子使用的线程是我们自己new的,下面我们改成线程池来试试:
ManualResetEvent:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;

namespace ThreadTest
{
    public class threadPoolTest2
    {
        public static ManualResetEvent manualReset = new ManualResetEvent(false);
        public static void Test()
        {
            ThreadPool.QueueUserWorkItem(new WaitCallback(Menthod1), manualReset);
            ThreadPool.QueueUserWorkItem(new WaitCallback(Menthod2), manualReset);
            ThreadPool.QueueUserWorkItem(new WaitCallback(Menthod3), manualReset);

            manualReset.WaitOne();
            Console.WriteLine("主线程的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("---------主线程-----------");
            Console.ReadLine();
        }

        public static void Menthod1(object obj)
        {
            manualReset.WaitOne();
            Console.WriteLine("线程1的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("---------线程1-----------");
        }

        public static void Menthod2(object obj)
        {
            manualReset.WaitOne();
            Console.WriteLine("线程2的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("obj:{0}", obj);
            Console.WriteLine("----------线程2----------");
        }

        public static void Menthod3(object obj)
        {
            Thread.Sleep(1000);
            Console.WriteLine("线程3的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("激活线程...");
            Console.WriteLine("--------线程3------------");
            manualReset.Set();
        }
    }
}

运行结果:

AutoResetEvent:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;

namespace ThreadTest
{
    public class threadPoolTest1
    {
        public static AutoResetEvent autoReset = new AutoResetEvent(false);
        public static void Test() {
            ThreadPool.QueueUserWorkItem(new WaitCallback(Menthod1), autoReset);
            ThreadPool.QueueUserWorkItem(new WaitCallback(Menthod2), autoReset);
            ThreadPool.QueueUserWorkItem(new WaitCallback(Menthod3), autoReset);

            autoReset.WaitOne();
            Console.WriteLine("主线程的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("---------主线程-----------");
            Console.ReadLine();
        }

        public static void Menthod1(object obj)
        {
            autoReset.WaitOne();
            Console.WriteLine("线程1的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("---------线程1-----------");
        }

        public static void Menthod2(object obj)
        {
            autoReset.WaitOne();
            Console.WriteLine("线程2的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("obj:{0}", obj);
            Console.WriteLine("----------线程2----------");
        }

        public static void Menthod3(object obj)
        {
            Thread.Sleep(1000);
            Console.WriteLine("线程3的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("激活线程...");
            Console.WriteLine("--------线程3------------");
            autoReset.Set();
        }
    }
}

运行结果:

再次运行:

可以看到,和上面的结果一样,说明这两个事件可以控制线程池中的线程执行顺序。
上面的示例中我们使用ManualResetEvent激活的主线程、线程1、线程2这三个线程的执行顺序打乱的,为了精确控制,比如我们要让线程1在线程2之前执行,那么可以再用一个ManualResetEvent来实现,如:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;

namespace ThreadTest
{
    public class threadPoolTest3
    {
        public static ManualResetEvent manualReset = new ManualResetEvent(false);
        public static ManualResetEvent manualReset1 = new ManualResetEvent(false);
        public static void Test()
        {
            ThreadPool.QueueUserWorkItem(new WaitCallback(Menthod1), manualReset);
            ThreadPool.QueueUserWorkItem(new WaitCallback(Menthod2), manualReset);
            ThreadPool.QueueUserWorkItem(new WaitCallback(Menthod3), manualReset);

            manualReset.WaitOne();
            Console.WriteLine("主线程的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("---------主线程-----------");
            Console.ReadLine();
        }

        public static void Menthod1(object obj)
        {
            manualReset.WaitOne();
            Console.WriteLine("线程1的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("---------线程1-----------");
            manualReset1.Set();
        }

        public static void Menthod2(object obj)
        {
            manualReset.WaitOne();
            manualReset1.WaitOne();
            Console.WriteLine("线程2的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("obj:{0}", obj);
            Console.WriteLine("----------线程2----------");
        }

        public static void Menthod3(object obj)
        {
            Thread.Sleep(1000);
            Console.WriteLine("线程3的ID:{0}", Thread.CurrentThread.ManagedThreadId);
            Console.WriteLine("激活线程...");
            Console.WriteLine("--------线程3------------");
            manualReset.Set();
        }
    }
}

运行结果:

参考:
http://www.cnblogs.com/yizhu2000/archive/2007/10/12/922637.html
http://www.cnblogs.com/net66/admin/archive/2005/08/02/206067.html
https://kb.cnblogs.com/page/68545/
http://www.cnblogs.com/zhaow/articles/8524420.html

原文地址:https://www.cnblogs.com/zhaow/p/9035357.html