当前位置: 首页 > news >正文

C# 中的多线程

目录

  • 前言
    • 1. 基础知识
        • 1.简介及概念
          • 1.1Join 和 Sleep
          • 1.2线程是如何工作的
          • 1.3线程 vs 进程
          • 1.4线程的使用与误用
        • 2创建和启动线程
          • 2.1向线程传递数据
          • 2.2线程命名
          • 2.3前台与后台线程
          • 2.4线程优先级

前言

最近在看代码的过程中,发现有很多地方涉及到多线程、异步编程,这是比较重要且常用的知识点,而本人在这方面还理解尚浅,因此开始全面学习C#中的多线程,全文内容摘抄自一位前辈的网站:网址链接
本人在个别地方做了一些便于个人理解的修改。
在这里插入图片描述

1. 基础知识

1.简介及概念

C# 支持通过多线程并行执行代码,线程有其独立的执行路径,能够与其它线程同时执行。

一个 C# 客户端程序(Console 命令行、WPF 以及 Windows Forms)开始于一个单线程,这个线程(也称为“主线程”)是由 CLR 和操作系统自动创建的,并且也可以再创建其它线程。以下是一个简单的使用多线程的例子:

主线程创建了一个新线程t来不断打印字母 “ y “,与此同时,主线程在不停打印字母 “ x “。

namespace App
{
    class ThreadTest
    {
        static void Main()
        {
            Thread t = new Thread(WriteY);  // 创建新线程
            t.Start();                       // 启动新线程,执行WriteY()

            // 同时,在主线程做其它事情
            for (int i = 0; i < 1000; i++)
            {
                Console.Write("x");
            }
               
        }
        static void WriteY()
        {
            for (int i = 0; i < 1000; i++) Console.Write("y");
        }
    }
}

输出结果:

xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxyyyyyxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy

线程一旦启动,线程的IsAlive属性值就会为true,直到线程结束。当传递给Thread的构造方法的委托执行完成时,线程就会结束。一旦结束,该线程不能再重新启动。

CLR 为每个线程分配各自独立的栈空间,因此局部变量是独立的。在下面的例子中,我们定义一个拥有局部变量的方法,然后在主线程和新创建的线程中同时执行该方法。


namespace App
{
    class ThreadTest
    {
        static void Main()
        {
            new Thread(Go).Start();      // 在新线程执行Go()
            Go();                         // 在主线程执行Go()
        }

        static void Go()
        {
            // 定义和使用局部变量 - 'cycles'
            for (int cycles = 0; cycles < 5; cycles++)
            {
                Console.Write("?");
            }
               
        }
    }
}

输出结果:

??????????

变量cycles的副本是分别在各自的栈中创建的,因此才会输出 10 个问号。

线程可以通过对同一对象的引用来共享数据。例如:


namespace App
{
    class ThreadTest
    {
        bool done;

        static void Main()
        {
            ThreadTest tt = new ThreadTest();   // 创建一个公共的实例
            new Thread(tt.Go).Start();
            tt.Go();
        }

        // 注意: Go现在是一个实例方法
        void Go()
        {
            if (!done)
            { 
                done = true;
                Console.WriteLine("Done");
            }
        }
    }
}

输出结果:

Done 

由于两个线程是调用了同一个的ThreadTest实例上的Go(),它们共享了done字段,因此输出结果是一次 “ Done “,而不是两次。

像下面这种情况就会打印两次“Done”:


namespace App
{
    class ThreadTest
    {
        bool done;

        static void Main()
        {
            ThreadTest tt = new ThreadTest();   // 创建一个公共的实例
            new Thread(new ThreadTest().Go).Start();
            tt.Go();
        }

        // 注意: Go现在是一个实例方法
        void Go()
        {
            if (!done)
            { 
                done = true;
                Console.WriteLine("Done");
            }
        }
    }
}

输出结果:

Done
Done

静态字段提供了另一种在线程间共享数据的方式,以下是一个静态的done字段的例子:


namespace App
{
    class ThreadTest
    {
        static bool done;    // 静态字段在所有线程中共享

        static void Main()
        {
            new Thread(Go).Start();
            Go();
        }

        static void Go()
        {
            if (!done)
            {
                done = true;
                Console.WriteLine("Done");
            }
         
        }
    }
}

输出结果:

Done 

以上两个例子引出了一个关键概念线程安全(thread safety)。上述两个例子的输出实际上是不确定的:” Done “ 有可能会被打印两次。如果在Go方法里调换指令的顺序,” Done “ 被打印两次的几率会大幅提高:


namespace App
{
    class ThreadTest
    {
        static bool done;    // 静态字段在所有线程中共享

        static void Main()
        {
            new Thread(Go).Start();
            Go();
        }

        static void Go()
        {
            if (!done)
            {
                Console.WriteLine("Done");
                done = true;       
            }
         
        }
    }
}

输出结果:

Done
Done

这个问题是因为一个线程对if中的语句估值的时候,另一个线程正在执行WriteLine语句,这时done还没有被设置为true。

修复这个问题需要在读写公共字段时,获得一个排它锁(互斥锁,exclusive lock )。C# 提供了lock来达到这个目的:


namespace App
{
    class ThreadSafe
    {
        static bool done;
        static readonly object locker = new object();

        static void Main()
        {
            new Thread(Go).Start();
            Go();
        }

        static void Go()
        {
            lock (locker)
            {
                if (!done)
                { 
                    Console.WriteLine("Done");
                    done = true; 
                }
            }
        }
    }
}

输出结果:

Done 

当两个线程同时争夺一个锁的时候(例子中的locker),一个线程等待,或者说阻塞,直到锁变为可用。这样就确保了在同一时刻只有一个线程能进入临界区(critical section,不允许并发执行的代码),所以 “ Done “ 只被打印了一次。像这种用来避免在多线程下的不确定性的方式被称为线程安全(thread-safe)。

在线程间共享数据是造成多线程复杂、难以定位的错误的主要原因。尽管这通常是必须的,但应该尽可能保持简单。

一个线程被阻塞时,不会消耗 CPU 资源。

1.1Join 和 Sleep

可以通过调用Join方法来等待另一个线程结束,例如:

namespace App
{
    class ThreadSafe
    {
        private static void Method()
        {
            Thread.Sleep(5000);
            Console.WriteLine("当前线程:" + Thread.CurrentThread.Name);
        }

        static void Main(string[] args)
        {
            Thread.CurrentThread.Name = "MainThread";

            Thread thread = new Thread(Method);
            thread.Name = "Thread";
            thread.Start();
            //会阻止主线程,直到thread线程终结(线程方法返回或线程遇到异常)
            //输出:当前线程:Thread
            //      主线程:MainThread
            //可以注销此句对比输出结果
            thread.Join();

            Console.WriteLine("主线程:" + Thread.CurrentThread.Name);

            Console.Read();
        }
    }
}

输出结果:

当前线程:Thread
主线程:MainThread

程序运行后,会先等待5秒,然后打印“当前线程:Thread”,然后再打印:“主线程:MainThread”。如果把 thread.Join()注销后,会打印不一样的结果,代码如下:


namespace App
{
    class ThreadSafe
    {
        private static void Method()
        {
            Thread.Sleep(5000);
            Console.WriteLine("当前线程:" + Thread.CurrentThread.Name);
        }

        static void Main(string[] args)
        {
            Thread.CurrentThread.Name = "MainThread";

            Thread thread = new Thread(Method);
            thread.Name = "Thread";
            thread.Start();
            //会阻止主线程,直到thread线程终结(线程方法返回或线程遇到异常)
            //输出:当前线程:Thread
            //      主线程:MainThread
            //可以注销此句对比输出结果
            //thread.Join();

            Console.WriteLine("主线程:" + Thread.CurrentThread.Name);

            Console.Read();
        }
    }
}

输出结果:

主线程:MainThread
当前线程:Thread

程序运行后,会先打印:“主线程:MainThread”,然后等待5秒,然后打印“当前线程:Thread”。

Thread.Sleep会将当前的线程阻塞一段时间:

Thread.Sleep (TimeSpan.FromHours (1));  // 阻塞 1小时
Thread.Sleep (500);                     // 阻塞 500 毫秒

当使用Sleep或Join等待时,线程是阻塞(blocked)状态,因此不会消耗 CPU 资源。

Thread.Sleep(0)会立即释放当前的时间片,将 CPU 资源出让给其它线程。Framework 4.0 新的Thread.Yield()方法与其相同,除了它只会出让给运行在相同处理器核心上的其它线程。

Sleep(0)和Yield在调整代码性能时偶尔有用,它也是一个很好的诊断工具,可以用于找出线程安全(thread safety)的问题。如果在你代码的任意位置插入Thread.Yield()会影响到程序,基本可以确定存在 bug。

1.2线程是如何工作的

线程在内部由一个线程调度器(thread scheduler)管理,一般 CLR 会把这个任务交给操作系统完成。线程调度器确保所有活动的线程能够分配到适当的执行时间,并且保证那些处于等待或阻塞状态(例如,等待排它锁或者用户输入)的线程不消耗CPU时间。

在单核计算机上,线程调度器会进行时间切片(time-slicing),快速的在活动线程中切换执行。在 Windows 操作系统上,一个时间片通常在十几毫秒(译者注:默认 15.625ms),远大于 CPU 在线程间进行上下文切换的开销(通常在几微秒区间)。

在多核计算机上,多线程的实现是混合了时间切片和真实的并发,不同的线程同时运行在不同的 CPU 核心上。几乎可以肯定仍然会使用到时间切片,因为操作系统除了要调度其它的应用,还需要调度自身的线程。

线程的执行由于外部因素(比如时间切片)被中断称为被抢占(preempted)。在大多数情况下,线程无法控制其在何时及在什么代码处被抢占。

1.3线程 vs 进程

好比多个进程并行在计算机上执行,多个线程是在一个进程中并行执行。进程是完全隔离的,而线程是在一定程度上隔离。一般的,线程与运行在相同程序中的其它线程共享堆内存。这就是线程为何有用的部分原因,一个线程可以在后台获取数据,而另一个线程可以同时显示已获取到的数据。

1.4线程的使用与误用

多线程有许多用处,下面是通常的应用场景:

  • 维持用户界面的响应
    使用工作线程并行运行时间消耗大的任务,这样主UI线程就仍然可以响应键盘、鼠标的事件。

  • 有效利用 CPU
    多线程在一个线程等待其它计算机或硬件设备响应时非常有用。当一个线程在执行任务时被阻塞,其它线程就可以利用这个空闲出来的CPU核心。

  • 并行计算
    在多核心或多处理器的计算机上,计算密集型的代码如果通过分治策略(divide-and-conquer,见第 5 部分)将工作量分摊到多个线程,就可以提高计算速度。

  • 推测执行(speculative execution)
    在多核心的计算机上,有时可以通过推测之后需要被执行的工作,提前执行它们来提高性能。LINQPad就使用了这个技术来加速新查询的创建。另一种方式就是可以多线程并行运行解决相同问题的不同算法,因为预先不知道哪个算法更好,这样做就可以尽早获得结果。

  • 允许同时处理请求
    在服务端,客户端请求可能同时到达,因此需要并行处理(如果你使用 ASP.NET、WCF、Web Services 或者 Remoting,.NET Framework 会自动创建线程)。这在客户端同样有用,例如处理 P2P 网络连接,或是处理来自用户的多个请求。

多线程同样也会带来缺点,最大的问题是它提高了程序的复杂度。使用多个线程本身并不复杂,复杂的是线程间的交互(一般是通过共享数据)。无论线程间的交互是否有意为之,都会带来较长的开发周期,以及带来间歇的、难以重现的 bug。因此,最好保证线程间的交互尽量少,并坚持简单和已被证明的多线程交互设计。这篇文章主要就是关于如何处理这种复杂的问题,如果能够移除线程间交互,那会轻松许多。

一个好的策略是把多线程逻辑使用可重用的类封装,以便于独立的检验和测试。.NET Framework 提供了许多高层的线程构造,之后会讲到。

当频繁地调度和切换线程时(并且如果活动线程数量大于 CPU 核心数),多线程会增加资源和 CPU 的开销,线程的创建和销毁也会增加开销。多线程并不总是能提升程序的运行速度,如果使用不当,反而可能降低速度。 例如,当需要进行大量的磁盘 I/O 时,几个工作线程顺序执行可能会比 10 个线程同时执行要快。(在使用 Wait 和 Pulse 进行同步中,将会描述如何实现 生产者 / 消费者队列,它提供了上述功能。)

2创建和启动线程

像我们在简介中看到的那样,使用Thread类的构造方法来创建线程,通过传递ThreadStart委托来指明线程从哪里开始运行,下面是ThreadStart委托的定义:

public delegate void ThreadStart();

调用Start方法后,线程开始执行,直到它所执行的方法返回后,线程终止。下面这个例子使用完整的 C# 语法创建TheadStart委托:

class ThreadTest
{
  static void Main()
  {
    Thread t = new Thread (new ThreadStart (Go));
    t.Start();   // 在新线程运行 GO()
    Go();        // 同时在主线程运行 GO()
  }

  static void Go()
  {
    Console.WriteLine ("hello!");
  }
}

输出结果:

hello!
hello!

在这个例子中,线程t执行Go()方法,几乎同时主线程也执行Go()方法,结果将打印两个 hello。

线程也可以使用更简洁的语法创建,使用方法组(method group),让 C# 编译器推断ThreadStart委托类型:

Thread t = new Thread (Go);    // 无需显式使用 ThreadStart

另一个快捷的方式是使用 lambda 表达式或者匿名方法:

static void Main()
{
  Thread t = new Thread ( () => Console.WriteLine ("Hello!") );
  t.Start();
}
2.1向线程传递数据

向一个线程的目标方法传递参数最简单的方式是使用 lambda 表达式调用目标方法,在表达式内指定参数:


namespace App
{
    class ThreadTest
    {
        static void Main()
        {
            Thread t = new Thread(() => Print("Hello from t!"));
            t.Start();
        }

        static void Print(string message)
        {
            Console.WriteLine(message);
        }
    }
}

输出结果:

Hello from t!

使用这种方式,可以向方法传递任意数量的参数。甚至可以将整个实现封装为一个多语句的 lambda 表达式:

new Thread (() =>
{
  Console.WriteLine ("I'm running on another thread!");
  Console.WriteLine ("This is so easy!");
}).Start();

另一个方法是向Thread的Start方法传递参数:


namespace App
{
    class ThreadTest
    {
        static void Main()
        {
            Thread t = new Thread(Print);
            t.Start("Hello from t!");
        }

        static void Print(object messageObj)
        {
            string message = (string)messageObj;    // 需要强制类型转换
            Console.WriteLine(message);
        }
    }
}

输出结果:

Hello from t!

可以这样是因为Thread的构造方法通过重载来接受两个委托中的任意一个:

public delegate void ThreadStart();
public delegate void ParameterizedThreadStart (object obj);

ParameterizedThreadStart的限制是它只接受一个参数。并且由于它是object类型,通常需要类型转换。

Lambda 表达式与被捕获变量: 如我们所见,lambda 表达式是向线程传递数据的最强大的方法。然而必须小心,不要在启动线程之后误修改被捕获变量(captured variables)。例如,考虑下面的例子:


namespace App
{
    class ThreadTest
    {
        static void Main()
        {
            for (int i = 0; i < 10; i++)
            {
                new Thread(() => Console.Write(i)).Start();
            }      
        }
    }
}

输出结果是不确定的!可能是这样0223557799。

问题在于变量i在整个循环中指向相同的内存地址。所以,每一个线程在调用Console.Write时,都在使用这个值在运行时会被改变的变量!

解决方法就是使用临时变量,如下所示:


namespace App
{
    class ThreadTest
    {
        static void Main()
        {
            for (int i = 0; i < 10; i++)
            {
                // new Thread(() => Console.Write(i)).Start();
                var temp = i;
                new Thread(() => { Console.WriteLine(temp); }).Start();
            }      
        }
    }
}

输出结果:

0
1
2
3
4
5
6
7
8
9

变量temp对于每一个循环迭代是局部的。所以,每一个线程会捕获一个不同的内存地址,从而不会产生问题。我们可以使用更为简单的代码来演示前面的问题:


namespace App
{
    class ThreadTest
    {
        static void Main()
        {
            string text = "t1";
            Thread t1 = new Thread(() => Console.WriteLine(text));

            text = "t2";
            Thread t2 = new Thread(() => Console.WriteLine(text));

            t1.Start();
            t2.Start();
        }
    }
}

输出结果:

t2
t2
2.2线程命名

每一个线程都有一个 Name 属性,我们可以设置它以便于调试。这在 Visual Studio 中非常有用,因为线程的名字会显示在线程窗口(Threads Window)与调试位置(Debug Location)工具栏上。线程的名字只能设置一次,以后尝试修改会抛出异常。

静态的Thread.CurrentThread属性会返回当前执行的线程。在下面的例子中,我们设置主线程的名字:

namespace App
{
    class ThreadNaming
    {
        static void Main()
        {
            Thread.CurrentThread.Name = "main";
            Thread worker = new Thread(Go);
            worker.Name = "worker";
            worker.Start();
            Go();
        }

        static void Go()
        {
            Console.WriteLine("Hello from " + Thread.CurrentThread.Name);
        }
    }
}

输出结果也不是唯一的,有两种,具体要看系统对线程的调度情况:
结果1:

Hello from main
Hello from worker

结果2:

Hello from worker
Hello from main
2.3前台与后台线程

默认情况下,显式创建的线程都是前台线程(foreground threads)。只要有一个前台线程在运行,程序就可以保持存活,而后台线程(background threads)并不能保持程序存活。当一个程序中所有前台线程停止运行时,仍在运行的所有后台线程会被强制终止。

线程的前台/后台状态与它的优先级和执行时间的分配无关。

可以通过线程的IsBackground属性来查询或修改线程的前后台状态。如下面的例子:

class PriorityTest
{
  static void Main (string[] args)
  {
    Thread worker = new Thread ( () => Console.ReadLine() );
    if (args.Length > 0) worker.IsBackground = true;//IsBackground = true表示把该线程设置为后台线程
    worker.Start();
  }
}

如果这个程序以无参数的形式运行,工作线程会默认为前台,并在ReadLine时等待用户输入回车。此时主线程退出,但是程序仍然在运行,因为有一个前台线程依然存活。

相反,如果给Main()传递了参数,工作线程设置为后台状态,当主线程结束时,程序几乎立即退出(终止ReadLine需要一咪咪时间)。

当进程以这种方式结束时,后台线程执行栈中所有finally块就会被避开。如果程序依赖finally(或是using)块来执行清理工作,例如释放资源或是删除临时文件,就可能会产生问题。为了避免这种问题,在退出程序时可以显式的等待这些后台线程结束。有两种方法可以实现:

  • 如果是自己创建的线程,在线程上调用Join方法。
  • 如果是使用线程池线程,使用事件等待句柄。

在任一种情况下,都应指定一个超时时间,从而可以放弃由于某种原因而无法正常结束的线程。这是后备的退出策略:我们希望程序最后可以关闭,而不是让用户去开任务管理器

如果用户使用任务管理器强行终止了 .NET 进程,所有线程都会被当作后台线程一般丢弃。这是通过观察得出的结论,并不是通过文档,而且可能会因为 CLR 和操作系统的版本不同而有不同的行为。

前台线程不需要这种处理,但是必须小心避免会使线程无法结束的 bug。程序无法正常退出的一个很有可能的原因就是仍有前台线程存在。

2.4线程优先级

相关文章:

  • 【C++】类和对象(下)—— 再谈构造函数 | static成员 | C++11补丁 |友元
  • 初始C语言(2)
  • DevOps CI/CD 常见面试题
  • SpringSecurity (二) --------- 认证
  • Mybatis新增数据,存在就更新,不存在就添加
  • 嵌入式linux驱动之并发
  • 【Nginx】三、Nginx实现四层负载均衡Nginx实现限流防盗链流量镜像
  • 【第一弹】Python题库刷题---完事开头难,从基础题开始
  • 【命令】进程常用命令
  • Python进阶
  • 支持JDK19虚拟线程的web框架,上篇:体验
  • SpringBoot+Vue实现前后端分离旅游资源信息系统
  • 【Web前端】一文带你吃透HTML(下篇)
  • 基于C51小车测速
  • 【附源码】计算机毕业设计SSM某大学校园竞赛管理系统
  • - C#编程大幅提高OUTLOOK的邮件搜索能力!
  • github从入门到放弃(1)
  • HTTP中的ETag在移动客户端的应用
  • Java|序列化异常StreamCorruptedException的解决方法
  • js中的正则表达式入门
  • October CMS - 快速入门 9 Images And Galleries
  • Python进阶细节
  • Python学习笔记 字符串拼接
  • React16时代,该用什么姿势写 React ?
  • socket.io+express实现聊天室的思考(三)
  • spring security oauth2 password授权模式
  • windows下如何用phpstorm同步测试服务器
  • 翻译 | 老司机带你秒懂内存管理 - 第一部(共三部)
  • 工作踩坑系列——https访问遇到“已阻止载入混合活动内容”
  • 利用阿里云 OSS 搭建私有 Docker 仓库
  • 七牛云 DV OV EV SSL 证书上线,限时折扣低至 6.75 折!
  • 前端路由实现-history
  • 使用API自动生成工具优化前端工作流
  • 使用parted解决大于2T的磁盘分区
  • 听说你叫Java(二)–Servlet请求
  • 移动互联网+智能运营体系搭建=你家有金矿啊!
  • 继 XDL 之后,阿里妈妈开源大规模分布式图表征学习框架 Euler ...
  • 树莓派用上kodexplorer也能玩成私有网盘
  • ​flutter 代码混淆
  • ​VRRP 虚拟路由冗余协议(华为)
  • ###51单片机学习(2)-----如何通过C语言运用延时函数设计LED流水灯
  • #我与Java虚拟机的故事#连载04:一本让自己没面子的书
  • (16)UiBot:智能化软件机器人(以头歌抓取课程数据为例)
  • (4)Elastix图像配准:3D图像
  • (Repost) Getting Genode with TrustZone on the i.MX
  • (zhuan) 一些RL的文献(及笔记)
  • (附源码)spring boot基于Java的电影院售票与管理系统毕业设计 011449
  • (附源码)springboot家庭财务分析系统 毕业设计641323
  • (强烈推荐)移动端音视频从零到上手(上)
  • (一)基于IDEA的JAVA基础12
  • (转)C#开发微信门户及应用(1)--开始使用微信接口
  • (最全解法)输入一个整数,输出该数二进制表示中1的个数。
  • ***通过什么方式***网吧
  • .NET基础篇——反射的奥妙
  • @cacheable 是否缓存成功_让我们来学习学习SpringCache分布式缓存,为什么用?