c# 多线程-异步-WaitHandle-AutoResetEvent
来源:互联网 发布:多特网软件下载 编辑:程序博客网 时间:2024/06/11 05:04
先表明,向作者致敬http://www.cnblogs.com/leslies2/archive/2012/02/07/2310495.html 风尘浪子
前半部分是复制风尘浪子的,从 三 开始,互联网收集整理. 感谢互联网,感谢open source. 重要是,大家能够领悟,掌握和运用多线程的知识.
一、线程的定义
1. 1 进程、应用程序域与线程的关系
进程(Process)是Windows系统中的一个基本概念,它包含着一个运行程序所需要的资源。进程之间是相对独立的,一个进程无法访问另一个 进程的数据(除非利用分布式计算方式),一个进程运行的失败也不会影响其他进程的运行,Windows系统就是利用进程把工作划分为多个独立的区域的。进 程可以理解为一个程序的基本边界。
应用程序域(AppDomain)是一个程序运行的逻辑区域,它可以视为一个轻量级的进程,.NET的程序集正是在应用程序域中运行的,一个进程可 以包含有多个应用程序域,一个应用程序域也可以包含多个程序集。在一个应用程序域中包含了一个或多个上下文context,使用上下文CLR就能够把某些 特殊对象的状态放置在不同容器当中。
线程(Thread)是进程中的基本执行单元,在进程入口执行的第一个线程被视为这个进程的主线程。在.NET应用程序中,都是以Main()方法 作为入口的,当调用此方法时系统就会自动创建一个主线程。线程主要是由CPU寄存器、调用栈和线程本地存储器(Thread Local Storage,TLS)组成的。CPU寄存器主要记录当前所执行线程的状态,调用栈主要用于维护线程所调用到的内存与数据,TLS主要用于存放线程的状 态信息。
进程、应用程序域、线程的关系如下图,一个进程内可以包括多个应用程序域,也有包括多个线程,线程也可以穿梭于多个应用程序域当中。但在同一个时刻,线程只会处于一个应用程序域内。
1.2 多线程
在单CPU系统的一个单位时间(time slice)内,CPU只能运行单个线程,运行顺序取决于线程的优先级别。如果在单位时间内线程未能完成执行,系统就会把线程的状态信息保存到线程的本地 存储器(TLS) 中,以便下次执行时恢复执行。而多线程只是系统带来的一个假像,它在多个单位时间内进行多个线程的切换。因为切换频密而且单位时间非常短暂,所以多线程可 被视作同时运行。
适当使用多线程能提高系统的性能,比如:在系统请求大容量的数据时使用多线程,把数据输出工作交给异步线程,使主线程保持其稳定性去处理其他问题。但需要注意一点,因为CPU需要花费不少的时间在线程的切换上,所以过多地使用多线程反而会导致性能的下降。
二、线程的基础知识
2.1 System.Threading.Thread类
System.Threading.Thread是用于控制线程的基础类,通过Thread可以控制当前应用程序域中线程的创建、挂起、停止、销毁。
它包括以下常用公共属性:
2.1.1 线程的标识符
ManagedThreadId是确认线程的唯一标识符,程序在大部分情况下都是通过Thread.ManagedThreadId来辨别线程的。 而Name是一个可变值,在默认时候,Name为一个空值 Null,开发人员可以通过程序设置线程的名称,但这只是一个辅助功能。
2.1.2 线程的优先级别
.NET为线程设置了Priority属性来定义线程执行的优先级别,里面包含5个选项,其中Normal是默认值。除非系统有特殊要求,否则不应该随便设置线程的优先级别。
2.1.3 线程的状态
通过ThreadState可以检测线程是处于Unstarted、Sleeping、Running 等等状态,它比 IsAlive 属性能提供更多的特定信息。
前面说过,一个应用程序域中可能包括多个上下文,而通过CurrentContext可以获取线程当前的上下文。
CurrentThread是最常用的一个属性,它是用于获取当前运行的线程。
2.1.4 System.Threading.Thread的方法
Thread 中包括了多个方法来控制线程的创建、挂起、停止、销毁,以后来的例子中会经常使用。
2.1.5 开发实例
以下这个例子,就是通过Thread显示当前线程信息
static void Main(string[] args) { Thread thread = Thread.CurrentThread; thread.Name = "Main Thread"; string threadMessage = string.Format("Thread ID:{0}\n Current AppDomainId:{1}\n " + "Current ContextId:{2}\n Thread Name:{3}\n " + "Thread State:{4}\n Thread Priority:{5}\n", thread.ManagedThreadId, Thread.GetDomainID(), Thread.CurrentContext.ContextID, thread.Name, thread.ThreadState, thread.Priority); Console.WriteLine(threadMessage); Console.ReadKey(); }
三、以ThreadStart方式实现多线程
3.1使用ThreadStart
static void Main(string[] args) { //Thread th = new Thread(Sleep); Thread th = new Thread(new ThreadStart(Sleep)); th.Start(); for (int i = 0; i < 10; i++) { Console.WriteLine("这里是主线程在工作" + i); } } //模拟执行长时间的任务 static void Sleep() { ThreadMessage("Sleep"); Console.WriteLine("我还没有执行完呢,请耐心等待...."); Thread.Sleep(3000); } static void ThreadMessage(string data) { string message = string.Format("ThreadName is {0} ThreadId is:{1}", data, Thread.CurrentThread.ManagedThreadId); Console.WriteLine(message); }
运行后你会发现,Sleep()方法 还没有执行完(睡眠3分钟模拟长时间任务),主线程已经执行完成. 这就是多线程的好处.
3.2 ParameterizedThreadStart 带参数的
static void Main(string[] args) { //Thread th = new Thread(Sleep); Thread th = new Thread(new ParameterizedThreadStart(Sleep)); th.Start("Sleep"); for (int i = 0; i < 10; i++) { Console.WriteLine("这里是主线程在工作" + i); } } //模拟执行长时间的任务 static void Sleep(object state) { string name = (string)state; ThreadMessage(name); Console.WriteLine("我还没有执行完呢,请耐心等待...."); Thread.Sleep(3000); } static void ThreadMessage(string data) { string message = string.Format("ThreadName is {0} ThreadId is:{1}", data, Thread.CurrentThread.ManagedThreadId); Console.WriteLine(message); }
运行结果和上面那差不多一样.
3.3 匿名函数(委托)在多线程的运用
想必大家发现了这2个例子中都有一句注释的Thread th = new Thread(Sleep),这是为什么呢? 你可以把注释打开,下面的th注释掉,运行下,你会发现,两次都一样,这又是为什么呢?因为在实例化th的时候,有4个重载方法,其中2个就是ThreadStart和ParameterizedThreadStart
我们都知道ThreadStart和ParameterizedThreadStart和2个委托,而委托最大的作用就是传递一个方法, 在C#2.0就引入了匿名方法(3.0以及更高,lambda表达式取代了匿名方法)我们来看看匿名方法在这里能给我们带来点什么惊喜(方便)
class Program { static void Main(string[] args) { //Thread th = new Thread(delegate() { //在new ThreadStart()中 // ShowMessage("ShowMessage"); //}); //Thread th = new Thread(new ThreadStart(delegate() { // ShowMessage("ShowMessage"); //})); //Thread th = new Thread(new ThreadStart(() => //{ // ShowMessage("ShowMessage"); //})); Thread th = new Thread(delegate(object o) //new ParameterizedThreadStart()中的 { ShowMessage("ShowMessage"); }); //Thread th = new Thread(new ParameterizedThreadStart(delegate(object o) { // ShowMessage("ShowMessage"); //})); //Thread th = new Thread(t => ShowMessage("show")); th.Start(); for (int i = 0; i < 10; i++) { Console.WriteLine("这里是主线程在工作" + i); } } //模拟执行长时间的任务 static void Sleep(object state) { string name = (string)state; ThreadMessage(name); Console.WriteLine("我还没有执行完呢,请耐心等待...."); Thread.Sleep(3000); } //这里的参数不为object,你可以定义任何类型,任何参数 static void ShowMessage(string message) { ThreadMessage(message); Console.WriteLine("我还没有执行完呢,请耐心等待...."); Thread.Sleep(3000); } static void ThreadMessage(string data) { string message = string.Format("ThreadName is {0} ThreadId is:{1}", data, Thread.CurrentThread.ManagedThreadId); Console.WriteLine(message); } }}
上面的几种方式都可以用. 我为什么要用个ShowMessage(string msg) 方法来测试呢 ? 细心你会发现ParameterizedThreadStart委托他定义的参数为object,用匿名函数就可以解决这问题.如果你对委托,匿名函数不太熟悉的话,你就要补习一下关于委托的知识了.
3.4 前台线程and后台线程
注意以上两个例子都没有使用Console.ReadKey(),但系统依然会等待异步线程完成后才会结束。这是因为使用Thread.Start()启动的线程默认为前台线程,而系统必须等待所有前台线程运行结束后,应用程序域才会自动卸载。
在第二节曾经介绍过线程Thread有一个属性IsBackground,通过把此属性设置为true,就可以把线程设置为后台线程!这时应用程序域将在主线程完成时就被卸载(主线程关闭),而不会等待异步线程的运行。
3.5 线程的一些方法
Thread.Sleep()大家都很熟悉了,休眠多长时间,里面是毫秒.1秒=1000毫秒. Join() 表面意思是把线程加入,也就是这线程完事后主线程才被卸载.
你可以把子线程设置成后台线程,然后调用这个方法,就不会发现’一闪而过’的现象了. Thread.Suspend()与 Thread.Resume()是在Framework1.0 就已经存在的老方法了,它们分别可以挂起、恢复线程。但在Framework2.0中就已经明确排斥这两个方法。这是因为一旦某个线程占用了已有的资源,再使用Suspend()使线程长期处于挂起状态,当在其他线程调用这些资源的时候就会引起死锁!所以在没有必要的情况下应该避免使用这两个方法。
若想终止正在运行的线程,可以使用Abort()方法。在使用Abort()的时候,将引发一个特殊异常 ThreadAbortException 。
若想在线程终止前恢复线程的执行,可以在捕获异常后 ,在catch(ThreadAbortException ex){…} 中调用Thread.ResetAbort()取消终止。
下面的例子是 终止线程和取消终止的例子(拷贝风尘浪子的)
class Program { static void Main(string[] args) { Console.WriteLine("Main threadId is:" + Thread.CurrentThread.ManagedThreadId); Thread thread = new Thread(new ThreadStart(AsyncThread)); thread.Start(); Console.ReadKey(); } //以异步方式调用 static void AsyncThread() { try { string message = string.Format("\nAsync threadId is:{0}", Thread.CurrentThread.ManagedThreadId); Console.WriteLine(message); for (int n = 0; n < 10; n++) { //当n等于4时,终止线程 if (n >= 4) { Thread.CurrentThread.Abort(n); } Thread.Sleep(300); Console.WriteLine("The number is:" + n.ToString()); } } catch (ThreadAbortException ex) { //输出终止线程时n的值 if (ex.ExceptionState != null) Console.WriteLine(string.Format("Thread abort when the number is: {0}!", ex.ExceptionState.ToString())); //取消终止,继续执行线程 Thread.ResetAbort(); Console.WriteLine("Thread ResetAbort!"); } //线程结束 Console.WriteLine("Thread Close!"); } }
到此,我们学会了运用多线程,可不能沾沾自喜,这可只是刚刚开始. 前面说了通过ThreadStart创建的线程比较难管理,创建过多性能也会下降.主要是因为ThreadStart创建的线程不能循环利用,比如我们For循环个list,每一个model都开启个线程去执行任务,当前面的线程执行完了,也就销毁了,后面的还是要重新创建,不停的创建线程是很耗时的.由此可见 .NET为线程管理专门设置了一个CLR线程池.
四、CLR线程池的工作者线程
4.1 关于CLR线程池
使用ThreadStart与ParameterizedThreadStart建立新线程非常简单,但通过此方法建立的线程难于管理,若建立过多的线程反而会影响系统的性能。
有 见及此,.NET引入CLR线程池这个概念。CLR线程池并不会在CLR初始化的时候立刻建立线程,而是在应用程序要创建线程来执行任务时,线程池才初始 化一个线程。线程的初始化与其他的线程一样。在完成任务以后,该线程不会自行销毁,而是以挂起的状态返回到线程池。直到应用程序再次向线程池发出请求时, 线程池里挂起的线程就会再度激活执行任务。这样既节省了建立线程所造成的性能损耗,也可以让多个任务反复重用同一线程,从而在应用程序生存期内节约大量开销.
4.2 工作者线程与I/O线程
CLR线程池分为工作者线程(workerThreads)与I/O线程 (completionPortThreads) 两种,工作者线程是主要用作管理CLR内部对象的运作,I/O(Input/Output) 线程顾名思义是用于与外部系统交换信息,IO线程的细节将在下一节详细说明。
通过ThreadPool.GetMax(out int workerThreads,out int completionPortThreads )和 ThreadPool.SetMax( int workerThreads, int completionPortThreads)两个方法可以分别读取和设置CLR线程池中工作者线程与I/O线程的最大线程数。在 Framework2.0中最大线程默认为25*CPU数,在Framewok3.0、4.0中最大线程数默认为250*CPU数,在近年 I3,I5,I7 CPU出现后,线程池的最大值一般默认为1000、2000。
若想测试线程池中有多少的线程正在投入使用,可以通过ThreadPool.GetAvailableThreads( out int workerThreads,out int completionPortThreads ) 方法。
使用CLR线程池的工作者线程一般有两种方式,一是直接通过 ThreadPool.QueueUserWorkItem() 方法,二是通过委托(异步操作是加入在线程池中的),下面将逐一细说。
4.3 通过QueueUserWorkItem启动工作者线程
ThreadPool线程池中包含有两个静态方法可以直接启动工作者线程:
一为 ThreadPool.QueueUserWorkItem(WaitCallback)
二为 ThreadPool.QueueUserWorkItem(WaitCallback,Object)
先把WaitCallback委托指向一个带有Object参数的无返回值方法,再使用 ThreadPool.QueueUserWorkItem(WaitCallback) 就可以异步启动此方法,此时异步方法的参数被视为null 。
class Program { static void Main(string[] args) { ThreadPool.QueueUserWorkItem(new WaitCallback(Sleep)); for (int i = 0; i < 10; i++) { Console.WriteLine("这里是主线程在工作" + i); } Console.ReadKey(); } static void Sleep(object state) { ThreadMessage("sleep"); Console.WriteLine("我还没有执行完呢,请耐心等待...."); Thread.Sleep(3000); } static void ThreadMessage(string data) { string message = string.Format("ThreadName is {0} ThreadId is:{1}", data, Thread.CurrentThread.ManagedThreadId); Console.WriteLine(message); } }
这是个不带参数的, ThreadPool.QueueUserWorkItem()有2个参数,第二个是个object类型
class Program { static void Main(string[] args) { Person person = new Person() { Name = "Somnus", Age = 10 }; ThreadPool.QueueUserWorkItem(new WaitCallback(ShowMessage), person); for (int i = 0; i < 10; i++) { Console.WriteLine("这里是主线程在工作" + i); } Console.ReadKey(); } static void ShowMessage(object state) { Person person = (Person)state; Console.WriteLine("学生姓名是{0},年龄为{1}", person.Name, person.Age); Thread.Sleep(3000); } } class Person { public string Name { get; set; } public int Age { get; set; } //public WaitHandle Wait { get; set; } }
我们创建了一个Person类,在加入线程池的时候,我们传了一个person , 在ShowMessage()方法中,得到传过来的person,并显示人的信息.
那在这里,我们可以效仿3.3匿名函数在线程池中的应用呢? 答案是可以的.
4.4 线程池中的异常处理
多线程执行任务的时候,有时候总是要出错嘛, 重要的是,我们能catch的住异常.
class Program { static void Main(string[] args) { Person person = new Person() { Name = "Somnus", Age = 10 }; try { ThreadPool.QueueUserWorkItem(new WaitCallback(ShowMessage), person); } catch (Exception ex) { //写日志 } for (int i = 0; i < 10; i++) { Console.WriteLine("这里是主线程在工作" + i); } Console.ReadKey(); } static void ShowMessage(object state) { Person person = (Person)state; throw new Exception("这里出错了,快catch住我"); Console.WriteLine("学生姓名是{0},年龄为{1}", person.Name, person.Age); Thread.Sleep(3000); } } class Person { public string Name { get; set; } public int Age { get; set; } //public WaitHandle Wait { get; set; } }
这样? No~ 结果会差强人意的,我们并没有catch住他. 那该在哪try呢. 对, 在委托调用那方法中. 我们这样干,
class Program { static void Main(string[] args) { Person person = new Person() { Name = "Somnus", Age = 10 }; ThreadPool.QueueUserWorkItem(new WaitCallback(ShowMessage), person); for (int i = 0; i < 10; i++) { Console.WriteLine("这里是主线程在工作" + i); } Console.ReadKey(); } static void ShowMessage(object state) { try { Person person = (Person)state; throw new Exception("这里出错了,快catch住我"); Console.WriteLine("学生姓名是{0},年龄为{1}", person.Name, person.Age); Thread.Sleep(3000); } catch (Exception ex) { //写日志 } } } class Person { public string Name { get; set; } public int Age { get; set; } //public WaitHandle Wait { get; set; } }
这样,程序就不会报错了.
如果有好几个任务方法, 那我们要写好几个try{}catch{}这样肯定不是我们想要的,看看下面的封装
class Program { static void Main(string[] args) { Person person = new Person() { Name = "Somnus", Age = 10 }; ThreadExecutor.Execute(new WaitCallback(ShowMessage), person); for (int i = 0; i < 10; i++) { Console.WriteLine("这里是主线程在工作" + i); } Console.ReadKey(); } static void ShowMessage(object state) { Person person = (Person)state; throw new Exception("这里出错了,快catch住我"); Console.WriteLine("学生姓名是{0},年龄为{1}", person.Name, person.Age); Thread.Sleep(3000); } } class Person { public string Name { get; set; } public int Age { get; set; } //public WaitHandle Wait { get; set; } } public class ThreadExecutor { public static bool Execute(System.Threading.WaitCallback callback, object state) { try { return System.Threading.ThreadPool.QueueUserWorkItem((data) => { try { callback(data); } catch (Exception ex) { //写日志 } }, state); } catch (Exception e) { //写日志 } return false; } public static bool Execute(System.Threading.WaitCallback callback) { try { return System.Threading.ThreadPool.QueueUserWorkItem((data) => { try { callback(data); } catch (Exception ex) { //写日志 } }); } catch (Exception e) { //写日志 } return false; } }
其中Execute()方法就是把ThreadPool.QueueUserWorkItem用匿名函数的方法进行了封装,catch住了异常.
我们在前面说过了,线程池中的线程,默认是后台线程,前面几个例子,最后都有句Console.ReadKey(),就是如果不手动关闭主程序,主程序是不会自动关的.
这肯定不是我们想要的,那我们怎么判断所有子线程运行完毕后,关闭主线程呢
4.5 WaitHandle.WaitAll() 等待所有子线程完成后,关闭主线程 多个线程之间的协调工作
class Program { static void Main(string[] args) { WaitHandle[] waits = new WaitHandle[2] { new AutoResetEvent(false), new AutoResetEvent(false) }; Person person = new Person() { Name = "Somnus", Age = 10, Wait=waits[0] }; Person person2 = new Person() { Name = "cnblogs", Age = 20, Wait = waits[1] }; ThreadExecutor.Execute(new WaitCallback(ShowMessage),person); ThreadExecutor.Execute(new WaitCallback(ShowMessage), person2); for (int i = 0; i < 10; i++) { Console.WriteLine("这里是主线程在工作" + i); } WaitHandle.WaitAll(waits); //WaitHandle.WaitAny(waits); } static void ShowMessage(object state) { Person person = (Person)state; AutoResetEvent are = (AutoResetEvent)person.Wait; Console.WriteLine("学生姓名是{0},年龄为{1}", person.Name, person.Age); Thread.Sleep(3000); are.Set(); } } class Person { public string Name { get; set; } public int Age { get; set; } public WaitHandle Wait { get; set; } } public class ThreadExecutor { public static bool Execute(System.Threading.WaitCallback callback, object state) { try { return System.Threading.ThreadPool.QueueUserWorkItem((data) => { try { callback(data); } catch (Exception ex) { //写日志 } }, state); } catch (Exception e) { //写日志 } return false; } public static bool Execute(System.Threading.WaitCallback callback) { try { return System.Threading.ThreadPool.QueueUserWorkItem((data) => { try { callback(data); } catch (Exception ex) { //写日志 } }); } catch (Exception e) { //写日志 } return false; } }
WaitHandle.WaitAll(),最大可监测64个WaitHandler ,如果你需要的多线程比较多,你可以分批,中间Sleep()一段时间,就可以了.
WaitHandle.WaitAny(),其中某一个线程完成后,就退出主线程.
4.6 委托类
使用CLR线程池中的工作者线程,最灵活最常用的方式就是使用委托的异步方法.委托包括下面3个重要方法:Invoke(),BeginInvoke(),EndInvoke()
当调用Invoke()方法时,对应此委托的所有方法都会被执行。而BeginInvoke与EndInvoke则支持委托方法的异步调用,由BeginInvoke启动的线程都属于CLR线程池中的工作者线程。
class Program { static void Main(string[] args) { SleepDelegate sleepDelegate = new SleepDelegate(Sleep); IAsyncResult result = sleepDelegate.BeginInvoke("Sleep", null, null); string data = sleepDelegate.EndInvoke(result); Console.WriteLine(data); Console.ReadKey(); } delegate string SleepDelegate(object o); //模拟执行长时间的任务 static string Sleep(object state) { string name = (string)state; ThreadMessage(name); Console.WriteLine("我还没有执行完呢,请耐心等待...."); Thread.Sleep(3000); return "Hello" + name; } static void ThreadMessage(string data) { string message = string.Format("ThreadName is {0} ThreadId is:{1}", data, Thread.CurrentThread.ManagedThreadId); Console.WriteLine(message); } }
委托还有个可以调用回调函数的
class Program { static void Main(string[] args) { SleepDelegate sleepDelegate = new SleepDelegate(Sleep); IAsyncResult result = sleepDelegate.BeginInvoke("Sleep", new AsyncCallback(Complete), "这里可以传个object值啊"); Console.ReadKey(); } delegate string SleepDelegate(object o); //模拟执行长时间的任务 static string Sleep(object state) { string name = (string)state; ThreadMessage(name); Console.WriteLine("我还没有执行完呢,请耐心等待...."); Thread.Sleep(3000); return "Hello" + name; } static void Complete(IAsyncResult iresult) { AsyncResult result = (AsyncResult)iresult; SleepDelegate sleepDelegate = (SleepDelegate)result.AsyncDelegate; string obj = (string)result.AsyncState; string data = sleepDelegate.EndInvoke(result); Console.WriteLine(obj + data); } static void ThreadMessage(string data) { string message = string.Format("ThreadName is {0} ThreadId is:{1}", data, Thread.CurrentThread.ManagedThreadId); Console.WriteLine(message); } }
ok,就写到这吧,委托异步处理的异常处理和等待所有线程完成,都可以参照4.4和4.5.
同时,在delegate.EndInvoke() 处也可以catch住异常.(这个在执行exe时候可看到效果,直接调试程序要报错)
这篇文章,是自己对多线程学习和总结吧. 对于多线程中的,线程安全,不是太了解,可能是下一步要探究的对象吧.
http://blog.csdn.net/wilsonke/article/details/7616984
- c# 多线程-异步-WaitHandle-AutoResetEvent
- c# 多线程-异步-WaitHandle-AutoResetEvent
- 多线程中的锁系统(三)-WaitHandle、AutoResetEvent、ManualResetEvent
- WaitHandle、AutoResetEvent、ManualResetEvent整理
- WaitHandle——使用AutoResetEvent
- C#多线程同步(四)【AutoResetEvent】
- C# 多线程 AutoResetEvent和ManualResetEvent
- C#多线程之二:ManualResetEvent和AutoResetEvent
- C#多线程之二:ManualResetEvent和AutoResetEvent
- C#多线程之二:ManualResetEvent和AutoResetEvent
- C#多线程之ManualResetEvent和AutoResetEvent
- c#多线程之mutex,semaphore,autoresetevent,manualResetevent
- C#多线程之二:ManualResetEvent和AutoResetEvent
- C#多线程之二:ManualResetEvent和AutoResetEvent
- C# WaitHandle 使用教程
- C# AutoResetEvent
- C#多线程学习(七)AutoResetEvent 类讲解
- C#多线程同步事件及等待句柄AutoResetEvent 和 ManualResetEvent
- vs2013解决敲代码卡顿的问题
- 详述 @Autowired 和 @Resource 注解的区别
- 2016总结和展望
- Java web的URL地址参数传递中文乱码的解决方案
- NetScaler各种IP解释
- c# 多线程-异步-WaitHandle-AutoResetEvent
- fastjson混淆规则
- 产品经理职责
- 爬虫项目(一)爬虫+jsoup轻松爬知乎
- 与其被生活逼,不如被自己逼
- C++如何设计一个含指针的类(Boolan笔记第二周)
- 二叉树的前序、中序、后序遍历
- IAP与APP互相跳转的实现
- 设计模式六大原则(2):里氏替换原则