• 多线程使用~会多少? 【转载】


    多线程使用~会多少?

     

     

    前言

    多线程就是允许复杂的应用程序在同一时刻执行多项任务,.NET FrameWork的托管编码环境提供了一个完整而强大的线程模型,该模型允许编程人员精确控制在一个线程中的内容,线程何时退出,以及它访问多少数据。

    本文将要介绍什么时候用到线程、如何使用、遇到的坑。

     

    什么时候使用线程

       实际上,所有的程序都是在线程中执行的,所以理解.NET 和 Windows 如何执行线程,将有助于理解程序在运行时期的执行情况,Windows Form应用程序使用事件循环线程来处理用户界面交互,各个窗体在不同线程上执行,如果需要在Windows Forms之间交互,就需要在线程之间交互,ASP.NET 页面在IIS 的 多线程环境中执行一一同一页面上不同请求可以在不同线程上执行,同一页面可以同时在多个线程中执行。在访问ASP.NET 页面上的共享资源时,就会出现遇到线程问题。

     

    线程

      线程技术是指开发架构将应用程序一部分分为”线程“,使线程与程序其余部分的执行顺序不同,在绝大编程语言中,都有相当于Main()的方法,该方法中的每一行都要按顺序执行,下一行代码要在上一行代码执行完成之后再执行,线程是一种特殊的状况,是操作系统执行多任务的一部分,它允许应用程序的一部分独立于其他对象而单独执行,因此就脱离了正常的执行顺序

     

    进程

      当启动应用程序的时候,系统就会自动为系统分配内存和其他相关资源,内存和资源的物理分离就叫做进程,当然,应用程序可以启动多个进程,要  记住的是"应用程序"与"进程"并不是同义词。分配给进程的内存为其他进程分配的内存被隔离,只有所属的那个进程才可以访问它,所以每个进程都应有一个线程!

      在Windows中,通过访问Windows任务管理器,就可以非常直观的查看当前运行的进程。右击任务栏的空白选项就可以看到了。

     

     NET和C#对线程的支持

      .NET支持自由线程,所有的属于.NET的语言都可以使用线程,包括C#和VB.NET以及F#,前面说过"进程"是一个内存和资源的一个物理独立的部分。后来又提到,一个进程至少有一个线程。当MicroSoft设计.NET Framework时,新增了一个隔离层,称为应用程序域或AppDomain,应用程序并不向进程那样独立存在,而是进程内部的一个逻辑独立的部分。在一个进程中可以有多个程序域,这是非常不错的优点,通常,标准的进程不是用代理,就不可以访问其他进程的数据。使用代理非常消耗系统开销,编码也变得复杂,可是如我们引入程序域的概念,就可以在一个进程中启动多个应用程序。进程所提供的隔离区可以和应用程序一起使用,线程可以跨多个应用程序来执行,还不需要消耗因线程内部的分配而带来的系统开销,还有一个好处是,它们提供了类型检查功能。

       Microsoft把这些应用程序域的所有功能都封装在了一个名为System,AppDomain的类中,Microsoft.NET程序集与这些应用程序域有着非常密切的关系,只要将程序集加载到应用程序中,它就会载入AppDomain。除非特别指出,否则程序集就会被加载到调用代码的AppDomain中。应用程序域与线程也有直接的关系,它们可以保存一个或多个线程,就像进程一样。可是不同之处是,应用程序域可以在进程内创建,但不是创建新线程。  

     

    定义线程

    了解了有关的理论和模型后,现在要介绍一些实际的代码,下面的实例将使用AppDomain来设置数据,检索数据以及表示AppDomain在执行的线程,创建一个appdomain.cs。

    复制代码
    复制代码
    //定义应用程序域
            public AppDomain Domain;
            public int ThreadId;
            //设置值
            public void SetDomainData(string vName,string vValue)
            {
                Domain.SetData(vName,(object)vValue);
                ThreadId = AppDomain.GetCurrentThreadId();
            }
            //获取值
            public string GetDomainData(string name)
            {
                return (string)Domain.GetData(name);
            }
            public static void DomainMainGo()
            {
                string DataName = "MyData";
                string DataValue = "Some Data to be stored";
    
                Console.WriteLine("Retrieving current domain");
                MyAppDomain Obj = new MyAppDomain();
                Obj.Domain = AppDomain.CurrentDomain;
                    
                Console.WriteLine("Setting domain data");
                Obj.SetDomainData(DataName,DataValue);
    
                Console.WriteLine("Getting domain data");
                Console.WriteLine($"The Data found for key{DataName},is{Obj.GetDomainData(DataName)},running on thread id: {Obj.ThreadId}");
            }
     
    复制代码

    执行程序后,得到如下结果。

    这对于没有什么经验的C#开发人员说也很简单,下面看一下代码,说明发生了什么。这个类中第一段重要的代码如下所示:

    复制代码
    public void SetDomainData(string vName,string vValue)
            {
                Domain.SetData(vName,(object)vValue);
                ThreadId = AppDomain.GetCurrentThreadId();
            }
     
          这个方法的参数是要设置的数据名称及其值。注意当SetData()方 法传入参数时,做了一些与众不同的工作。这里将String 类型的值强制转换为object 数据类型,因为SetData()方法把object 数据类型作为其第二个参数。该方法只使用了一个String, 而String由System.Object派生而来,所以不用将这个变量强制转换为object数据类型。不过,要存储其他数据,就没这么容易处理了,进行这个转换只是提醒一下。在这个方法的最后,调用AppDomain对象的GetCurrentThreadId属性,就可以得到当前运行的ThreadId.
      再说一下Get的这个方法。
    复制代码
    public string GetDomainData(string name)
            {
                return (string)Domain.GetData(name);
            }
     

     这个方法非常简单,这里使用AppDomain类的GetData()方法,根据键值获取数据。把参数从GetDomainData方法传递给GetData,再把该方法的结果传递回调用方法。

     

    NET中的线程

    刚才说了线程是什么,介绍了很多基本知识,还有一些重要的概念,那么现在说一说.NET中的线程。说到这里你一定想起来了Systeam.Threading,这里就不列出那些属性或者方法表了,太多了。

    这里介绍一个简单的栗子,它并不适合于解释为什么要用线程,但去除了稍后讲到的所有复杂因素,创建一个新的控制台应用程序,把文件命名为simple_thread.cs。

    复制代码
    复制代码
    public class simple_thread
        {
            public void SimpleMethod()
            {   
                int i = 5;
                int x = 10;
                int result = i * x;
                Console.WriteLine($"This code calculated the value {result.ToString()} from threadID:{AppDomain.GetCurrentThreadId().ToString()}");
            }
            public static void MainGo()
            {
                simple_thread simple = new simple_thread();
                simple.SimpleMethod();
    
                ThreadStart ts = new ThreadStart(simple.SimpleMethod);
                Thread t = new Thread(ts);
                t.Start();
            }
        }
     
    复制代码

    执行以上代码,结果如下图所示:

    现在简单解释一下这个简单的代码,线程的功能都是封装在System.Threading命名空间中,因此,必须将这个命名空间导入到项目中。一旦导入了这个命名空间,就可以建立一个能在主线程上和新工作线程上执行的方法。在第二次执行SimpleMethod方法代码的时候,其实就是在另一个线程上执行的。

    上面的示例中,我们说明不了什么,因为我们无法显示不同的线程ID,毕竟还没有执行多个线程。为了模拟更高的真实性,我们再创建一个类,名为:do_something_thread.cs,其定义如下:

    复制代码
    复制代码
        public class DoSomethingThread
        {
            static void WorkerMethod()
            {
                for (int i=1;i<1000;i++)
                {
                    Console.WriteLine("Worker Thread:"+i.ToString());
                }
            }
            static void MainGo()
            {
                ThreadStart ts = new ThreadStart(WorkerMethod);
                Thread t = new Thread(ts);
                t.Start();
                for (int i=0;i<1000;i++)
                {
                    Console.WriteLine("Primary Thread"+i.ToString());
                }
            }
        }
     
    复制代码

    执行以上代码,结果如下图所示:

          因为这些代码没有涉及新的编码技术,所以这里不再逐一介绍。不过可以看出,执行时间在两个线程之间共享。在一个线程完成之前,另一个线程并没有完全停止。每个线程都会有一小段时间米执行。在一个线程用完它的执行时间后,下一个线程就开始在它的时间片中执行。这两个线程会一直这样交替执行下去,直到执行完毕。实际上,系统上有多于两个的线程在交替执行,共享时间片。在前面的应用程序中,就不只是在两个线程之间来回切换。事实上,该应用程序的线程在与当前运行在计算机上.的许多线程起 共享执行时间。
      现在回头看一下ThreadStart委托,使用这些委托k可以完成一些有趣的事情。比如呢,我们都做过后天权限管理,我们将要实现一个不同的角色去登录后台那么就会有不同的场景,列入在管理员登录后,就要运行一个后台进程,来收集报告数据。当报告完成后,ji就通知管理员,而对于普通用户,就不需要这个功能了,这就是ThreadStart的面向对象特性。下面我们创建一个ThreadStartBranching.cs。
    复制代码
    复制代码
        public class ThreadStartBranching
        {
            enum UserClass
            {
                ClassAdmin,
                ClassUser
            }
            static void AdminMethod()
            {
                Console.WriteLine("Admin Method");
            }
            static void UserMethod()
            {
                Console.WriteLine("User Method");
            }
            static void ExecuteFor(UserClass uc)
            {
                ThreadStart ts;
                ThreadStart tsAdmin = new ThreadStart(AdminMethod);
                ThreadStart tsUser = new ThreadStart(UserMethod);
                if (uc == UserClass.ClassAdmin)
                    ts = tsAdmin;
                else
                    ts = tsUser;
                Thread t = new Thread(ts);
                t.Start();
            }
           public static void MainGo()
            {
                //excute in the context of an admin user
                ExecuteFor(UserClass.ClassAdmin);
                ExecuteFor(UserClass.ClassUser);
                Console.ReadLine();
            }
        }
     
    复制代码

    以上代码的结果是非常简单的。

     

     线程的属性和方法

      Thread类有很多方法和属性,使用Systeam.Threading 命名空间可以使控制线程的执行简单得多。到目前为止,我们只是创建线程并启动它。

      下面再介绍两个Thread类的成员:Sleep()方法和IsAlive属性,线程何以睡眠一段时间,直到时间到了才会终端睡眠。要使线程睡眠直接Sleep方法即可。观察下面代码,创建一个threadSleep.cs文件。

    复制代码
    复制代码
    static void WorkFunction()
            {
                string ThreadState;
                for (int i = 1;i<50000;i++)
                {
                    if (i % 5000 == 0)
                    {
                        ThreadState = Thread.CurrentThread.ToString();
                        Console.WriteLine("Worker"+ThreadState);
                    }
                }
                Console.WriteLine("Worker Function Complete");
            }
            public static void MainGo()
            {
                string ThreadState;
                Thread t = new Thread(new ThreadStart(WorkFunction));
                t.Start();
                while (t.IsAlive)
                {
                    Console.WriteLine("Still waiting.Iam going back to sleep!!");
                    Thread.Sleep(1);
                }
                ThreadState = t.ThreadState.ToString();
                Console.WriteLine("He's finally dene! Thread state is "+ ThreadState);
            }
     
    复制代码

    输出结果如下:

    注意:在for循环中试验不同的值并传递到sleep方法中,就可以看到不同的结果。

     首先,创建了一个线程,直接创建了一个ThreadStart变量当作参数。使用IsAlice属性可以判断线程是否还在执行,代码的其他部分是没有标准的,但是要注意其他的点,首先利用sleep制定线程睡眠,给其他线程让出执行时间,传入的参数单位是毫秒。

     

     线程的优先级

          线程的优先级决定了各个线程之间相对的优先级。ThreadPriority 枚举定义了可用f设置线程优先级的值,可用的值是:
          ●Highest(最高值)
          ●AboveNormal(高于正常值)

          ●Normal(正常值)

          ●BelowNormal(低于正常值)

          ●Lowest(最低值)

          当运行库创建-个线程,但还没有分配优先级时,线程的初始优先级为Normal。不过,叮以使用ThreadPriority枚举改变优先级。在介绍线程优先级的例子之前,先讨论一下线程的优先级。创建一个简单的线程实例,显示当前线程thread_ priority.cs 的名称、状态和优先级信息:

    复制代码
    复制代码
     public class ThreadPriority
        {
            public static Thread worker;
            public static void MainGo()
            {
                Console.WriteLine("Entering void Main()");
                worker = new Thread(new ThreadStart(FindPriority));
                worker.Name = "FindPriority() Thread";
                worker.Start();
                Console.WriteLine("Exiting void Main()");
            }
            public static void FindPriority()
            {
                Console.WriteLine("Name:"+worker.Name);
                Console.WriteLine("State:"+worker.ThreadState.ToString());
                Console.WriteLine("Priority:"+worker.Priority.ToString());
            }
        }
     
    复制代码

    这段代码非常简单,定义了方法FindPriority(),该方法显示了当前线程的名称、状态和优先级状态。工作线程是以Normal优先级运行的。以下是改变优先级的代码。

    复制代码
    worker2.Priority = System.Threading.ThreadPriority.Highest;
     

     需要注意的是应用程序无法限制操作系统修改由开发人员为制定线程分配的优先级,因为应用程序控制着所有线程。它们知道如何给你安排,老铁。

     

    计时器和回调

      由于线程不像应用程序的其余代码那样次序运行,所以我们无法确定线程影响特定共享资源的动作,是否会在另一线程访问共享资源之前完成。所以为了解决这些问题。使用计时器,可以按特定的时间执行方法。检查是否完成。这是一个非常简单的模型。但可以利用到很多情况。

          计时器由两个对象组成: TimerCallback 和Timer。TimerCallback 委托定义了以指定间隔执行的方法,而Timer对象本身就是计时器。TimerCallback 将- 一个特定的方法与计时器联系起来。Timer的构造函数(由重载得到)需要4个参数。第一个是前面指定的TimerCallback对象,第二个是可用于将状态传输给指定方法的-一个对象。后两个参数分别是开始调用方法之后的时间,以及以后调用TimerCallback方法的时间间隔。这些参数可以是整型或者长整型,表示毫秒数。而在下面的内容中,使用的是System.TimeSpan对象,该对象可以以时钟滴答、毫秒、秒、分、小时或天为单位指定间隔。

    复制代码
    复制代码
     public class TimerExample
        {
            private string message;//消息
            private static Timer tmr;//定时器
            private static bool complete;//是否完成
        }
     
    复制代码

    上面的定义很简单,将tmr声明为静态变量,并适用于整个类。

    复制代码
    复制代码
        public class TimerExample
        {
            private string message;//消息
            private static Timer tmr;//定时器
            private static bool complete;//是否完成
            public void GenerateText()
            {
                StringBuilder sb = new StringBuilder();
                for (int i=1;i<200;i++)
                {
                    sb.Append("This is Line"+i.ToString()+ System.Environment.NewLine);
                }
                message = sb.ToString();
            }
            public void GetText(object state)
            {
                if (message == null)
                    return;
                Console.WriteLine("message is:"+message);
                tmr.Dispose();
                complete = true;
            }
            public void MainGo()
            {
                TimerExample obj = new TimerExample();
                Thread t = new Thread(new ThreadStart(GenerateText));
                t.Start();
                TimerCallback timerCallback = new TimerCallback(GetText);
                tmr = new Timer(timerCallback,null,TimeSpan.Zero,TimeSpan.FromSeconds(2));
                do
                {
                    if (complete)
                        break;
                } while (true);
                Console.WriteLine("Exiting Main.");
                Console.ReadLine();
            }
        }
     
    复制代码

    通过Timer定时器两秒一次触发,如果message还没有设置值,那这个方法就会退出,否则就输出了一个消息。然后由GC删除了计时器。

     

     线程的生命周期

      当你安排一个线程的时候,这个线程会经历几个状态,包括未启动,激活,睡眠状态,Thread类包含一些方法,允许启动,停止,恢复,终止,挂起以及等待线程。使用线程的ThreadState可以确定线程当前的状态。这个状态是一个枚举值,其定义如下。

    复制代码
    复制代码
        [Flags]
        public enum ThreadState
        {
            Running = 0,
            StopRequested = 1,
            SuspendRequested = 2,
            Background = 4,
            Unstarted = 8,
            Stopped = 16,
            WaitSleepJoin = 32,
            Suspended = 64,
            AbortRequested = 128,
            Aborted = 256
        }
     
    复制代码
     

    线程的方法

      线程还四大操作,如线程睡眠,中断线程,暂停及恢复线程,销毁线程,连接线程。那Sleep()就不多了,当线程进入了睡眠时,他就进入了WaitSleepJoin 状态。如果线程处于睡眠状态,在到达指定的睡眠时间之前唤醒线程的方法,就只有Interrupt()了。这个方法会重新放到调度队列里;下面创建一个ThreadInterupt.cs.以下是代码:

     

    唤醒线程

    复制代码
    复制代码
        public class ThreadSleppJoin
        {
            public static Thread sleeper;
            public static Thread worker;
            public static void MainGo()
            {
                Console.WriteLine("Entering the void Main!");
    
                sleeper = new Thread(new ThreadStart(SleepingThread));
                worker = new Thread(new ThreadStart(AwakeTheThread));
                sleeper.Start();
                worker.Start();
            }
            public static void SleepingThread()
            {
                for (int i = 1; i < 50; i++)
                {
                    Console.Write(i + "   ");
                    if (i == 10 || i == 20 || i == 30)
                    {
                        Console.WriteLine("Going to sleep at:" + i);
                        Thread.Sleep(20);
                    }
                }
            }
            public static void AwakeTheThread()
            {
                for (int i = 50; i < 100; i++)
                {
                    Console.Write(i + "   ");
                    if (sleeper.ThreadState == System.Threading.ThreadState.WaitSleepJoin)
                    {
                        Console.WriteLine("Interrupting the slepping thread");
                        sleeper.Interrupt();
                    }
                }
            }
        }
     
    复制代码

    执行以上代码结果如下


          在上面的例子中,当计数器达到10、20 和30时,第一个线程(sleeper线程)进入睡眠状态。第二个线程(worker线程)检查第一个线程是否处于睡跟状态。如果是,就中断第一个线程,将其放回到调度队列中。Interrupt(方 法是将处于睡眠状态的线程重新激活的最好方法,如果等待资源的过程结束,且希望线程进入激活状态,就可以使用这项功能。
     

    暂停及恢复线程

       Thread类的Supend()和Resume()方法可以用来暂停和恢复线程。Supend()方法将无限期地关闭线程,直到另一个线程将其唤醒。当调用Resume()方法时,线程会处于SuspendRequested或Suspended状态。

    复制代码
    复制代码
        public partial class Form1 : Form
        {
            private Thread primeNumberThread;
            public Form1()
            {
                InitializeComponent();
            }
    
            private void button1_Click(object sender, EventArgs e)
            {
                primeNumberThread = new Thread(new ThreadStart(AddItemToListBox));
                primeNumberThread.Name = "Prime Numbers Example";
                primeNumberThread.Priority = ThreadPriority.BelowNormal;
                primeNumberThread.Start();
            }
            long num = 0;
            public void AddItemToListBox()
            {
                while (1==1)
                {
                    if (primeNumberThread.ThreadState != System.Threading.ThreadState.Suspended || primeNumberThread.ThreadState != System.Threading.ThreadState.SuspendRequested)
                    {
                        Thread.Sleep(1000);
                        num += 1;
                        this.listBox1.Items.Add(num.ToString());
                    }
                    else
                        break;
                }
            }
    
            private void button2_Click(object sender, EventArgs e)
            {
                if (primeNumberThread.ThreadState==System.Threading.ThreadState.Running)
                {
                    primeNumberThread.Suspend();
                }
            }
    
            private void Form1_Load(object sender, EventArgs e)
            {
                this.Dispose();
            }
    
            private void button3_Click(object sender, EventArgs e)
            {
                if (primeNumberThread.ThreadState != System.Threading.ThreadState.Suspended || primeNumberThread.ThreadState != System.Threading.ThreadState.SuspendRequested)
                {
                    primeNumberThread.Resume();
                }
            }
        }
     
    复制代码

     微软提示:不要使用SuspendResume方法来同步线程活动。 有没有办法知道当你暂停执行一个线程的哪些代码。 如果您挂起线程安全权限评估期间持有锁,其他线程中AppDomain可能被阻止。 如果您挂起线程执行的类构造函数时,其他线程中AppDomain中尝试使用类被阻止。 可以很容易发生死锁。

     

     销毁线程

          Abort()方法可以用来销毁当前的线程。如果因某种原因(比如线程执行了太长时间或用户选择了取消)要终止线程,就可以使用Abort0方法。例如,如果搜索进程执行了很长时间,就可以终止该进程。搜索可以继续执行,但用户已经得到了需要的结果,就不再需要继续执行搜索例程上的线程了。在线程上调用Abort()方法时,会引发ThreadAbortException异常。如果没有在线程的代码中捕获该异常,线程就会终止。在多线程环境下访问的方法中编写一般异常处理代码之前,应慎重考虑一下。因为catch(Exception e)也会捕获ThreadAbortException异常(可能不希望从中恢复)。由此可见,ThreadAbortException异常并不容易停止,程序流也不会像期望的那样继续执行。
    复制代码
    private void button4_Click(object sender, EventArgs e)
            {
                primeNumberThread.Abort();
            }
     

    这样一个线程就被销毁了。就再也不存在了,如果还要去使用,就需要再去创建一个实例对象。

     

    连接线程

          Join()方法会暂停给定的线程,直到当前的线程终止。在给定的线程实例上调用Join()方法时,线程将置于WaitsleepJoin状态。如果.一个线程依赖于另.一个线程,就可以使用这个方法。连接两个线程的意思是当调用Join(方法时, 运行着的线程将进入WaitsleepJoin状态,而直到调用Join(方法 的线程完成了任务,该线程才会返回到Running状态。这听起来有点混乱,下面用一个例子thread joining.cs 来说明,其代码如下所示:
    复制代码
    复制代码
        public class Threadjoining
        {
            public static Thread SecondThread;
            public static Thread FirstThread;
            static void First()
            {
                for (int i=1;i<=50;i++)
                {
                    Console.Write(i+" ");
                }
            }
            static void Second()
            {
                FirstThread.Join();
                for(int i=51;i<=100;i++)
                {
                    Console.Write(i+" ");
                }
            }
            public static void MainGo()
            {
                FirstThread = new Thread(new ThreadStart(First));
                SecondThread = new Thread(new ThreadStart(Second));
                FirstThread.Start();
                SecondThread.Start();
            }
        }
     
    复制代码

    运行以上代码,结果如下.

          这个简单的示例的目的就是顺序地将数字输出到控制台,从1开始到50为止。First0方法将输出前50个数字,Second方法则输出从51到100的数字。如果Second()方法中没有FirstThread.Join 执行流就会在两个方法之间求回切换回,输出结果就会很混乱(试着注释掉该行,再次运行这个例子)。通过在Second()方 法中调用FirstThread.Join()方法,将暂停Second0方法的执行,直到FirstThread(First0方法)中的代码执行完毕。
          Join()方法是重载的;它惟-的参数可以是一个整数,也可以是一个TimeSpan,该方法将返回一个布尔值。调用这个方法的-一个重载版本后,线程会暂停,直到另一个线程结束或者超时(以先发生的为准)为止。如果线程已经结束,返回值就是True,否则将为False。
     

    线程不是万能的

          多线程应用程序需要很多资源。线程需要内存来存储线程本地的存储器,所使用的线程数受到可用的内存总数限制。目前,内存是相当便宜的,因此很多计算机有很大的内存。但是,并不是所有的计算机都是这样。如果在未知的硬件配置上运行应用程序,就不能假定应用程序有足够的内存,也不能假定只有一个进稈会产生线程,消耗系统资源。一台计算机有很多内存空间,并不意味着所有的内存都由一个应用程序使用。

          每一个线程还会导致额外的处理器开销。如果在应用程序中创建太多的线程,就会限制线程的总执行时间。因此,与执行线程包含的指令相比,处理器在线程之间切换所花的时间会更多。如果应用程序创建了更多的线程,应用程序获得的执行时间将比其他包含较少线程的进程更多。
     

     最后

    本文的所有相关示例,都是由我测试通过的,有问题的话,在下方留言我们一起讨论。我把代码放到了Coding上,其地址为:https://coding.net/u/zaranet/p/ThreadDemo/git/tree/master。  完结!

  • 相关阅读:
    网络需求分析课堂作业
    工程招标与投标课堂作业
    burpsuite Pro下载安装及破解 | JDK安装和配置
    渗透测试环境的搭建
    web应用基础架构
    为Linux环境安装图形化界面
    Linux基本操作
    markdown语法教程(更新中)
    VMware导入和删除虚拟机文件
    Java求幂集与List的浅拷贝深拷贝问题
  • 原文地址:https://www.cnblogs.com/yy1234/p/10238066.html
Copyright © 2020-2023  润新知