A. 线程池哪个语言用的多
Java语言
Java语言对于多线程的支持十分丰富,JDK本身提供了很多性能优良的库,包括ThreadPoolExecutor和ScheleThreadPoolExecutor等。
使用线程池原因:因为线程的创建、和清理都是需要耗费系统资源的。我们知道linux中线程实际上是由轻量级进程实现的,相对于纯理论上的线程这个开销还是有的。含洞假设某个线程的创建、运行和销毁的时间分别为T1、T2、T3,当T1+T3的时旁宴间相对于T2不可忽略时,线程池的就有必要引入了,尤其是处理数百万级的高并发处理时。线程池提升了多线程程序的性能,因为线程池里面的线程都是现成的而且能够重复使用,我们不需要临时创建大量线程,然后在任务结束时又销毁大量线程。一个理想的线程池能够谈启枯合理地动态调节池内线程数量,既不会因为线程过少而导致大量任务堆积,也不会因为线程过多了而增加额外的系统开销。
B. 麻烦解释一下linux下进程和线程有什么区别和联系,linux下多线程和多进程通信的实现方法,请通俗解释
兄弟看到你这么高的分我就找了些资料:也算是对昨天学的知识总结一下吧
一、先说概念不管是windows还是linux下的进程和线程概念都是一样的,只是管理进程和线程的方式不一样,这个是前提,到时候你可别问我windows下进程和线程啊。这个涉及到操作系统原理。下面给你解答。
说道进程不得不提作业这个名词 ,我想兄弟你电脑里不会有一个程序吧对不?当你的系统启动完毕后你看看你的任务管理器里是不是有很多进程呢?那么多程序是怎么调如内存呢?能理解吗?这里要明白程序和进程的关系,程序是你磁盘上的一个文件,当你需要它时进入内存后才成为进程,好比QQ在磁盘上就是一个文件而已,只有进入了内存才成为进程,进程是活动的。QQ要扫描你文件啊,记录你聊天记录啊,偷偷上传个啥东西什么的你也不知道对不,他是活动的。这个能明白吗?
再看作业,这个作业可不是你写作业的那个作业啊。系统一看好家伙你个QQ那么大的家伙你想一下子进入内存啊?没门!慢慢来嘛,系统就把QQ程序分为好几块,这几块不能乱分的,要符合自然结构就是循环啦选择啦这样的结构,你把人家循环结构咔嚓截断了,怎么让人家QQ运行啊?这就是作业要一块一块的进入内存,同时要为作业产生JCB(JOB CONTROL BLOCK)作业控制块,你进入内存不能乱跑啊,要听系统的话,你要是进入系统自己的内存。框一下,内存不能读写 对话框就出来了,严重点直接蓝脸给你!你懂得。这是window下的,linux下直接给你报错!没事了就!所一系统通过jcb控制进程。JCB包含了进程号优先级好多内容,你打开你的windows任务管理器看看进程是不是有好多属性啊?那就是PCB(PRCESS,CONTROL BLOCK)同理作业也包含那些内容只是多少而已。下面写出进程特点:
1、进程是分配计算机资源最小的单位。你想啊人是要用程序干活的吧?你把程序调入内存成了就成了进程,所以说进程是分配资源的最小单位。你在linux下打开终端输入top命令看是不是有好多进程?
2、进程有操作系统为作业产生。有“父进程”产生“子进程”之间是父子关系,并可以继续向下产生“子进程”。还拿QQ来说,你双击QQ.exe。QQ启动了输入账号密码打开主界面了。这时候你要聊天,QQ进程赶紧产生个“儿子”说 “儿子你去陪主人聊天去吧。这样子进程产生了。突然你想看美女要传照片这时候那个”儿子“有”生“了一个”儿子“说”儿子“你去传照片。那个“儿子领到任务去传照片了。这时你想关了QQ,QQ提示你说”你还有个“儿子”和“孙子”还在干活呢你真要结束吗?你蒽了确定。QQ对他“儿子”(你聊天窗口)说:”儿子啊对不起了,主人要关闭我你也不能活啊“咔嚓一下”儿子“死了,儿子死之前对他儿子说:“儿子啊你爷爷不让我活了,你也别活了咔嚓孙子也死了。最后世界安静了。这就是进程的父子关系。能明白吗?记住:进程之活动在内存中。不能使用CPU,只管分配资源。
再说线程:线程也产生在内存中并且在内存中存在相当长的时间,但它的活动区域主要在CPU中,并且运行和灭亡都存在于CPU中,可以这么说,线程是程序中能被系统调度进入CPU中最小程序单位,它能直接使用进程分配的CPU的资源。
还拿QQ来说当你要传文件时QQ总要判断一下文件的扩展名吧,ok这时那个”儿子“赶紧对它爸爸说我需要一个线程判断扩展名QQ赶紧对一个管这个的线程说:”快点去CPU里计算下那个扩展名是什么然后向主人报告计算完了就“死了”消亡了,但是它的线程还在内存中!还等着你下一次传文件然后计算然后消亡!
线程之间是相互独立的。一个在CPU,一个在内存里还能有关系吗对不?CPU在每一个瞬间只能进入一个线程,当线程进入CPU时立即产生一个新的线程,新线程仍停留在内存中,就好比上面那个传文件还会等着你再传文件再计算扩展名。
线程相对线程是独立的,但它在内存中并不是独立的,这就好比你不开QQ能用QQ传输文件吗?它只存在与进程分配的资源中,也就是说计算扩展名这个线程只能停留在QQ这个进程中,不能跑到别的进程里!!相当于程序产生了新的进程和线程,进程向CPU申请资源,再有线程来使用,他们都是为程序服务的只是分工不同!
因为你没提问linux下是怎么管理进程和线程的所以我就不回答了,这个问题我建议你还是看看《笨兔兔的故事》里面讲到了linux是怎么管理进程和线程的。挺幽默的比我说得还好。
你第二个问题说实话我回答不了你!我想你现在连进程和线程还没理解第二个你更理解不了了你说对不?我猜的其实你用C/C++不管是在windows下编程还是在Linux下编程思想都是一样的对吧,如果你理解了在windows下线程间通信,在linux更没问题了!
参考资料:黑客手册2009合订本非安全第一二季244页,245页,328页,329页,398页,399页
浅谈操作系统原理 (一 二三)
ubuntu中文论坛 笨兔兔的故事
http://forum.ubuntu.org.cn/viewtopic.php?f=120&t=267518
希望我的回答你能理解
C. 如何看懂《Linux多线程服务端编程
一:进程和线程
每个进程有自己独立的地址空间。“在同一个进程”还是“不在同一个进程”是系统功能划分的重要决策点。《Erlang程序设计》[ERL]把进程比喻为人:
每个人有自己的记忆(内存),人与人通过谈话(消息传递)来交流,谈话既可以是面谈(同一台服务器),也可以在电话里谈(不同的服务器,有网络通信)。面谈和电话谈的区别在于,面谈可以立即知道对方是否死了(crash,SIGCHLD),而电话谈只能通过周期性的心跳来判断对方是否还活着。
有了这些比喻,设计分布式系统时可以采取“角色扮演”,团队里的几个人各自扮演一个进程,人的角色由进程的代码决定(管登录的、管消息分发的、管买卖的等等)。每个人有自己的记忆,但不知道别人的记忆,要想知道别人的看法,只能通过交谈(暂不考虑共享内存这种IPC)。然后就可以思考:
·容错:万一有人突然死了
·扩容:新人中途加进来
·负载均衡:把甲的活儿挪给乙做
·退休:甲要修复bug,先别派新任务,等他做完手上的事情就把他重启
等等各种场景,十分便利。
线程的特点是共享地址空间,从而可以高效地共享数据。一台机器上的多个进程能高效地共享代码段(操作系统可以映射为同样的物理内存),但不能共享数据。如果多个进程大量共享内存,等于是把多进程程序当成多线程来写,掩耳盗铃。
“多线程”的价值,我认为是为了更好地发挥多核处理器(multi-cores)的效能。在单核时代,多线程没有多大价值(个人想法:如果要完成的任务是CPU密集型的,那多线程没有优势,甚至因为线程切换的开销,多线程反而更慢;如果要完成的任务既有CPU计算,又有磁盘或网络IO,则使用多线程的好处是,当某个线程因为IO而阻塞时,OS可以调度其他线程执行,虽然效率确实要比任务的顺序执行效率要高,然而,这种类型的任务,可以通过单线程的”non-blocking IO+IO multiplexing”的模型(事件驱动)来提高效率,采用多线程的方式,带来的可能仅仅是编程上的简单而已)。Alan Cox说过:”A computer is a state machine.Threads are for people who can’t program state machines.”(计算机是一台状态机。线程是给那些不能编写状态机程序的人准备的)如果只有一块CPU、一个执行单元,那么确实如Alan Cox所说,按状态机的思路去写程序是最高效的。
二:单线程服务器的常用编程模型
据我了解,在高性能的网络程序中,使用得最为广泛的恐怕要数”non-blocking IO + IO multiplexing”这种模型,即Reactor模式。
在”non-blocking IO + IO multiplexing”这种模型中,程序的基本结构是一个事件循环(event loop),以事件驱动(event-driven)和事件回调的方式实现业务逻辑:
[cpp] view plain
//代码仅为示意,没有完整考虑各种情况
while(!done)
{
int timeout_ms = max(1000, getNextTimedCallback());
int retval = poll(fds, nfds, timeout_ms);
if (retval<0){
处理错误,回调用户的error handler
}else{
处理到期的timers,回调用户的timer handler
if(retval>0){
处理IO事件,回调用户的IO event handler
}
}
}
这里select(2)/poll(2)有伸缩性方面的不足(描述符过多时,效率较低),Linux下可替换为epoll(4),其他操作系统也有对应的高性能替代品。
Reactor模型的优点很明显,编程不难,效率也不错。不仅可以用于读写socket,连接的建立(connect(2)/accept(2)),甚至DNS解析都可以用非阻塞方式进行,以提高并发度和吞吐量(throughput),对于IO密集的应用是个不错的选择。lighttpd就是这样,它内部的fdevent结构十分精妙,值得学习。
基于事件驱动的编程模型也有其本质的缺点,它要求事件回调函数必须是非阻塞的。对于涉及网络IO的请求响应式协议,它容易割裂业务逻辑,使其散布于多个回调函数之中,相对不容易理解和维护。
三:多线程服务器的常用编程模型
大概有这么几种:
a:每个请求创建一个线程,使用阻塞式IO操作。在Java 1.4引人NIO之前,这是Java网络编程的推荐做法。可惜伸缩性不佳(请求太多时,操作系统创建不了这许多线程)。
b:使用线程池,同样使用阻塞式IO操作。与第1种相比,这是提高性能的措施。
c:使用non-blocking IO + IO multiplexing。即Java NIO的方式。
d:Leader/Follower等高级模式。
在默认情况下,我会使用第3种,即non-blocking IO + one loop per thread模式来编写多线程C++网络服务程序。
1:one loop per thread
此种模型下,程序里的每个IO线程有一个event loop,用于处理读写和定时事件(无论周期性的还是单次的)。代码框架跟“单线程服务器的常用编程模型”一节中的一样。
libev的作者说:
One loop per thread is usually a good model. Doing this is almost never wrong, some times a better-performance model exists, but it is always a good start.
这种方式的好处是:
a:线程数目基本固定,可以在程序启动的时候设置,不会频繁创建与销毁。
b:可以很方便地在线程间调配负载。
c:IO事件发生的线程是固定的,同一个TCP连接不必考虑事件并发。
Event loop代表了线程的主循环,需要让哪个线程干活,就把timer或IO channel(如TCP连接)注册到哪个线程的loop里即可:对实时性有要求的connection可以单独用一个线程;数据量大的connection可以独占一个线程,并把数据处理任务分摊到另几个计算线程中(用线程池);其他次要的辅助性connections可以共享一个线程。
比如,在dbproxy中,一个线程用于专门处理客户端发来的管理命令;一个线程用于处理客户端发来的MySQL命令,而与后端数据库通信执行该命令时,是将该任务分配给所有事件线程处理的。
对于non-trivial(有一定规模)的服务端程序,一般会采用non-blocking IO + IO multiplexing,每个connection/acceptor都会注册到某个event loop上,程序里有多个event loop,每个线程至多有一个event loop。
多线程程序对event loop提出了更高的要求,那就是“线程安全”。要允许一个线程往别的线程的loop里塞东西,这个loop必须得是线程安全的。
在dbproxy中,线程向其他线程分发任务,是通过管道和队列实现的。比如主线程accept到连接后,将表示该连接的结构放入队列,并向管道中写入一个字节。计算线程在自己的event loop中注册管道的读事件,一旦有数据可读,就尝试从队列中取任务。
2:线程池
不过,对于没有IO而光有计算任务的线程,使用event loop有点浪费。可以使用一种补充方案,即用blocking queue实现的任务队列:
[cpp] view plain
typedef boost::functionFunctor;
BlockingQueue taskQueue; //线程安全的全局阻塞队列
//计算线程
void workerThread()
{
while (running) //running变量是个全局标志
{
Functor task = taskQueue.take(); //this blocks
task(); //在产品代码中需要考虑异常处理
}
}
// 创建容量(并发数)为N的线程池
int N = num_of_computing_threads;
for (int i = 0; i < N; ++i)
{
create_thread(&workerThread); //启动线程
}
//向任务队列中追加任务
Foo foo; //Foo有calc()成员函数
boost::function task = boost::bind(&Foo::calc,&foo);
taskQueue.post(task);
除了任务队列,还可以用BlockingQueue实现数据的生产者消费者队列,即T是数据类型而非函数对象,queue的消费者从中拿到数据进行处理。其实本质上是一样的。
3:总结
总结而言,我推荐的C++多线程服务端编程模式为:one (event) loop per thread + thread pool:
event loop用作IO multiplexing,配合non-blockingIO和定时器;
thread pool用来做计算,具体可以是任务队列或生产者消费者队列。
以这种方式写服务器程序,需要一个优质的基于Reactor模式的网络库来支撑,muo正是这样的网络库。比如dbproxy使用的是libevent。
程序里具体用几个loop、线程池的大小等参数需要根据应用来设定,基本的原则是“阻抗匹配”(解释见下),使得CPU和IO都能高效地运作。所谓阻抗匹配原则:
如果池中线程在执行任务时,密集计算所占的时间比重为 P (0 < P <= 1),而系统一共有 C 个 CPU,为了让这 C 个 CPU 跑满而又不过载,线程池大小的经验公式 T = C/P。(T 是个 hint,考虑到 P 值的估计不是很准确,T 的最佳值可以上下浮动 50%)
以后我再讲这个经验公式是怎么来的,先验证边界条件的正确性。
假设 C = 8,P = 1.0,线程池的任务完全是密集计算,那么T = 8。只要 8 个活动线程就能让 8 个 CPU 饱和,再多也没用,因为 CPU 资源已经耗光了。
假设 C = 8,P = 0.5,线程池的任务有一半是计算,有一半等在 IO 上,那么T = 16。考虑操作系统能灵活合理地调度 sleeping/writing/running 线程,那么大概 16 个“50%繁忙的线程”能让 8 个 CPU 忙个不停。启动更多的线程并不能提高吞吐量,反而因为增加上下文切换的开销而降低性能。
如果 P < 0.2,这个公式就不适用了,T 可以取一个固定值,比如 5*C。
另外,公式里的 C 不一定是 CPU 总数,可以是“分配给这项任务的 CPU 数目”,比如在 8 核机器上分出 4 个核来做一项任务,那么 C=4。
四:进程间通信只用TCP
Linux下进程间通信的方式有:匿名管道(pipe)、具名管道(FIFO)、POSIX消息队列、共享内存、信号(signals),以及Socket。同步原语有互斥器(mutex)、条件变量(condition variable)、读写锁(reader-writer lock)、文件锁(record locking)、信号量(semaphore)等等。
进程间通信我首选Sockets(主要指TCP,我没有用过UDP,也不考虑Unix domain协议)。其好处在于:
可以跨主机,具有伸缩性。反正都是多进程了,如果一台机器的处理能力不够,很自然地就能用多台机器来处理。把进程分散到同一局域网的多台机器上,程序改改host:port配置就能继续用;
TCP sockets和pipe都是操作文件描述符,用来收发字节流,都可以read/write/fcntl/select/poll等。不同的是,TCP是双向的,Linux的pipe是单向的,进程间双向通信还得开两个文件描述符,不方便;而且进程要有父子关系才能用pipe,这些都限制了pipe的使用;
TCP port由一个进程独占,且进程退出时操作系统会自动回收文件描述符。因此即使程序意外退出,也不会给系统留下垃圾,程序重启之后能比较容易地恢复,而不需要重启操作系统(用跨进程的mutex就有这个风险);而且,port是独占的,可以防止程序重复启动,后面那个进程抢不到port,自然就没法初始化了,避免造成意料之外的结果;
与其他IPC相比,TCP协议的一个天生的好处是“可记录、可重现”。tcpmp和Wireshark是解决两个进程间协议和状态争端的好帮手,也是性能(吞吐量、延迟)分析的利器。我们可以借此编写分布式程序的自动化回归测试。也可以用tcp之类的工具进行压力测试。TCP还能跨语言,服务端和客户端不必使用同一种语言。
分布式系统的软件设计和功能划分一般应该以“进程”为单位。从宏观上看,一个分布式系统是由运行在多台机器上的多个进程组成的,进程之间采用TCP长连接通信。
使用TCP长连接的好处有两点:一是容易定位分布式系统中的服务之间的依赖关系。只要在机器上运行netstat -tpna|grep 就能立刻列出用到某服务的客户端地址(Foreign Address列),然后在客户端的机器上用netstat或lsof命令找出是哪个进程发起的连接。TCP短连接和UDP则不具备这一特性。二是通过接收和发送队列的长度也较容易定位网络或程序故障。在正常运行的时候,netstat打印的Recv-Q和Send-Q都应该接近0,或者在0附近摆动。如果Recv-Q保持不变或持续增加,则通常意味着服务进程的处理速度变慢,可能发生了死锁或阻塞。如果Send-Q保持不变或持续增加,有可能是对方服务器太忙、来不及处理,也有可能是网络中间某个路由器或交换机故障造成丢包,甚至对方服务器掉线,这些因素都可能表现为数据发送不出去。通过持续监控Recv-Q和Send-Q就能及早预警性能或可用性故障。以下是服务端线程阻塞造成Recv-Q和客户端Send-Q激增的例子:
[cpp] view plain
$netstat -tn
Proto Recv-Q Send-Q Local Address Foreign
tcp 78393 0 10.0.0.10:2000 10.0.0.10:39748 #服务端连接
tcp 0 132608 10.0.0.10:39748 10.0.0.10:2000 #客户端连接
tcp 0 52 10.0.0.10:22 10.0.0.4:55572
五:多线程服务器的适用场合
如果要在一台多核机器上提供一种服务或执行一个任务,可用的模式有:
a:运行一个单线程的进程;
b:运行一个多线程的进程;
c:运行多个单线程的进程;
d:运行多个多线程的进程;
考虑这样的场景:如果使用速率为50MB/s的数据压缩库,进程创建销毁的开销是800微秒,线程创建销毁的开销是50微秒。如何执行压缩任务?
如果要偶尔压缩1GB的文本文件,预计运行时间是20s,那么起一个进程去做是合理的,因为进程启动和销毁的开销远远小于实际任务的耗时。
如果要经常压缩500kB的文本数据,预计运行时间是10ms,那么每次都起进程 似乎有点浪费了,可以每次单独起一个线程去做。
如果要频繁压缩10kB的文本数据,预计运行时间是200微秒,那么每次起线程似 乎也很浪费,不如直接在当前线程搞定。也可以用一个线程池,每次把压缩任务交给线程池,避免阻塞当前线程(特别要避免阻塞IO线程)。
由此可见,多线程并不是万灵丹(silver bullet)。
1:必须使用单线程的场合
据我所知,有两种场合必须使用单线程:
a:程序可能会fork(2);
实际编程中,应该保证只有单线程程序能进行fork(2)。多线程程序不是不能调用fork(2),而是这么做会遇到很多麻烦:
fork一般不能在多线程程序中调用,因为Linux的fork只克隆当前线程的thread of control,不可隆其他线程。fork之后,除了当前线程之外,其他线程都消失了。
这就造成一种危险的局面。其他线程可能正好处于临界区之内,持有了某个锁,而它突然死亡,再也没有机会去解锁了。此时如果子进程试图再对同一个mutex加锁,就会立即死锁。因此,fork之后,子进程就相当于处于signal handler之中(因为不知道调用fork时,父进程中的线程此时正在调用什么函数,这和信号发生时的场景一样),你不能调用线程安全的函数(除非它是可重入的),而只能调用异步信号安全的函数。比如,fork之后,子进程不能调用:
malloc,因为malloc在访问全局状态时几乎肯定会加锁;
任何可能分配或释放内存的函数,比如snprintf;
任何Pthreads函数;
printf系列函数,因为其他线程可能恰好持有stdout/stderr的锁;
除了man 7 signal中明确列出的信号安全函数之外的任何函数。
因此,多线程中调用fork,唯一安全的做法是fork之后,立即调用exec执行另一个程序,彻底隔断子进程与父进程的联系。
在多线程环境中调用fork,产生子进程后。子进程内部只存在一个线程,也就是父进程中调用fork的线程的副本。
使用fork创建子进程时,子进程通过继承整个地址空间的副本,也从父进程那里继承了所有互斥量、读写锁和条件变量的状态。如果父进程中的某个线程占有锁,则子进程同样占有这些锁。问题是子进程并不包含占有锁的线程的副本,所以子进程没有办法知道它占有了哪些锁,并且需要释放哪些锁。
尽管Pthread提供了pthread_atfork函数试图绕过这样的问题,但是这回使得代码变得混乱。因此《Programming With Posix Threads》一书的作者说:”Avoid using fork in threaded code except where the child process will immediately exec a new program.”。
b:限制程序的CPU占用率;
这个很容易理解,比如在一个8核的服务器上,一个单线程程序即便发生busy-wait,占满1个core,其CPU使用率也只有12.5%,在这种最坏的情况下,系统还是有87.5%的计算资源可供其他服务进程使用。
因此对于一些辅助性的程序,如果它必须和主要服务进程运行在同一台机器的话,那么做成单线程的能避免过分抢夺系统的计算资源。
D. 一文读懂Linux任务间调度原理和整个执行过程
在前文中,我们分析了内核中进程和线程的统一结构体task_struct,并分析进程、线程的创建和派生的过程。在本文中,我们会对任务间调度进行详细剖析,了解其原理和整个执行过程。由此,进程、线程部分的大体框架就算是介绍完了。本节主要分为三个部分:Linux内核中常见的调度策略,调度的基本结构体以及调度发生的整个流程。下面将详细展开说明。
Linux 作为一个多任务操作系统,将每个 CPU 的时间划分为很短的时间片,再通过调度器轮流分配给各个任务使用,因此造成多任务同时运行的错觉。为了维护 CPU 时间,Linux 通过事先定义的节拍率(内核中表示为 HZ),触发时间中断,并使用全局变量 Jiffies 记录了开机以来的节拍数。每发生一次时间中断,Jiffies 的值就加 1。节拍率 HZ 是内核的可配选项,可以设置为 100、250、1000 等。不同的系统可能设置不同的数值,可以通过查询 /boot/config 内核选项来查看它的配置值。
Linux的调度策略主要分为实时任务和普通任务。实时任务需求尽快返回结果,而普通任务则没有较高的要求。在前文中我们提到了task_struct中调度策略相应的变量为policy,调度优先级有prio, static_prio, normal_prio, rt_priority几个。优先级其实就是一个数值,对于实时进程来说,优先级的范围是 0 99;对于普通进程,优先级的范围是 100 139。数值越小,优先级越高。
实时调度策略主要包括以下几种
普通调度策略主要包括以下几种:
首先,我们需要一个结构体去执行调度策略,即sched_class。该类有几种实现方式
普通任务调度实体源码如下,这里面包含了 vruntime 和权重 load_weight,以及对于运行时间的统计。
在调度时,多个任务调度实体会首先区分是实时任务还是普通任务,然后通过以时间为顺序的红黑树结构组合起来,vruntime 最小的在树的左侧,vruntime最多的在树的右侧。以CFS策略为例,则会选择红黑树最左边的叶子节点作为下一个将获得 CPU 的任务。而这颗红黑树,我们称之为运行时队列(run queue),即struct rq。
其中包含结构体cfs_rq,其定义如下,主要是CFS调度相关的结构体,主要有权值相关变量、vruntime相关变量以及红黑树指针,其中结构体rb_root_cached即为红黑树的节点
对结构体dl_rq有类似的定义,运行队列由红黑树结构体构成,并按照deadline策略进行管理
对于实施队列相应的rt_rq则有所不同,并没有用红黑树实现。
下面再看看调度类sched_class,该类以函数指针的形式定义了诸多队列操作,如
调度类分为下面几种:
队列操作中函数指针指向不同策略队列的实际执行函数函数,在linux/kernel/sched/目录下,fair.c、idle.c、rt.c等文件对不同类型的策略实现了不同的函数,如fair.c中定义了
以选择下一个任务为例,CFS对应的是pick_next_task_fair,而rt_rq对应的则是pick_next_task_rt,等等。
由此,我们来总结一下:
有了上述的基本策略和基本调度结构体,我们可以形成大致的骨架,下面就是需要核心的调度流程将其拼凑成一个整体,实现调度系统。调度分为两种,主动调度和抢占式调度。
说到调用,逃不过核心函数schele()。其中sched_submit_work()函数完成当前任务的收尾工作,以避免出现如死锁或者IO中断等情况。之后首先禁止抢占式调度的发生,然后调用__schele()函数完成调度,之后重新打开抢占式调度,如果需要重新调度则会一直重复该过程,否则结束函数。
而__schele()函数则是实际的核心调度函数,该函数主要操作包括选取下一进程和进行上下文切换,而上下文切换又包括用户态空间切换和内核态的切换。具体的解释可以参照英文源码注释以及中文对各个步骤的注释。
其中核心函数是获取下一个任务的pick_next_task()以及上下文切换的context_switch(),下面详细展开剖析。首先看看pick_next_task(),该函数会根据调度策略分类,调用该类对应的调度函数选择下一个任务实体。根据前文分析我们知道,最终是在不同的红黑树上选择最左节点作为下一个任务实体并返回。
下面来看看上下文切换。上下文切换主要干两件事情,一是切换任务空间,也即虚拟内存;二是切换寄存器和 CPU 上下文。关于任务空间的切换放在内存部分的文章中详细介绍,这里先按下不表,通过任务空间切换实际完成了用户态的上下文切换工作。下面我们重点看一下内核态切换,即寄存器和CPU上下文的切换。
switch_to()就是寄存器和栈的切换,它调用到了 __switch_to_asm。这是一段汇编代码,主要用于栈的切换, 其中32位使用esp作为栈顶指针,64位使用rsp,其他部分代码一致。通过该段汇编代码我们完成了栈顶指针的切换,并调用__switch_to完成最终TSS的切换。注意switch_to中其实是有三个变量,分别是prev, next, last,而实际在使用时,我们会对last也赋值为prev。这里的设计意图需要结合一个例子来说明。假设有ABC三个任务,从A调度到B,B到C,最后C回到A,我们假设仅保存prev和next,则流程如下
最终调用__switch_to()函数。该函数中涉及到一个结构体TSS(Task State Segment),该结构体存放了所有的寄存器。另外还有一个特殊的寄存器TR(Task Register)会指向TSS,我们通过更改TR的值,会触发硬件保存CPU所有寄存器在当前TSS,并从新的TSS读取寄存器的值加载入CPU,从而完成一次硬中断带来的上下文切换工作。系统初始化的时候,会调用 cpu_init()给每一个 CPU 关联一个 TSS,然后将 TR 指向这个 TSS,然后在操作系统的运行过程中,TR 就不切换了,永远指向这个 TSS。当修改TR的值得时候,则为任务调度。
更多Linux内核视频教程文本资料免费领取后台私信【 内核大礼包 】自行获取。
在完成了switch_to()的内核态切换后,还有一个重要的函数finish_task_switch()负责善后清理工作。在前面介绍switch_to三个参数的时候我们已经说明了使用last的重要性。而这里为何让prev和last均赋值为prev,是因为prev在后面没有需要用到,所以节省了一个指针空间来存储last。
至此,我们完成了内核态的切换工作,也完成了整个主动调度的过程。
抢占式调度通常发生在两种情况下。一种是某任务执行时间过长,另一种是当某任务被唤醒的时候。首先看看任务执行时间过长的情况。
该情况需要衡量一个任务的执行时间长短,执行时间过长则发起抢占。在计算机里面有一个时钟,会过一段时间触发一次时钟中断,通知操作系统时间又过去一个时钟周期,通过这种方式可以查看是否是需要抢占的时间点。
时钟中断处理函数会调用scheler_tick()。该函数首先取出当前CPU,并由此获取对应的运行队列rq和当前任务curr。接着调用该任务的调度类sched_class对应的task_tick()函数进行时间事件处理。
以普通任务队列为例,对应的调度类为fair_sched_class,对应的时钟处理函数为task_tick_fair(),该函数会获取当前的调度实体和运行队列,并调用entity_tick()函数更新时间。
在entity_tick()中,首先会调用update_curr()更新当前任务的vruntime,然后调用check_preempt_tick()检测现在是否可以发起抢占。
check_preempt_tick() 先是调用 sched_slice() 函数计算出一个调度周期中该任务运行的实际时间 ideal_runtime。sum_exec_runtime 指任务总共执行的实际时间,prev_sum_exec_runtime 指上次该进程被调度时已经占用的实际时间,所以 sum_exec_runtime - prev_sum_exec_runtime 就是这次调度占用实际时间。如果这个时间大于 ideal_runtime,则应该被抢占了。除了这个条件之外,还会通过 __pick_first_entity 取出红黑树中最小的进程。如果当前进程的 vruntime 大于红黑树中最小的进程的 vruntime,且差值大于 ideal_runtime,也应该被抢占了。
如果确认需要被抢占,则会调用resched_curr()函数,该函数会调用set_tsk_need_resched()标记该任务为_TIF_NEED_RESCHED,即该任务应该被抢占。
某些任务会因为中断而唤醒,如当 I/O 到来的时候,I/O进程往往会被唤醒。在这种时候,如果被唤醒的任务优先级高于 CPU 上的当前任务,就会触发抢占。try_to_wake_up() 调用 ttwu_queue() 将这个唤醒的任务添加到队列当中。ttwu_queue() 再调用 ttwu_do_activate() 激活这个任务。ttwu_do_activate() 调用 ttwu_do_wakeup()。这里面调用了 check_preempt_curr() 检查是否应该发生抢占。如果应该发生抢占,也不是直接踢走当前进程,而是将当前进程标记为应该被抢占。
由前面的分析,我们知道了不论是是当前任务执行时间过长还是新任务唤醒,我们均会对现在的任务标记位_TIF_NEED_RESCUED,下面分析实际抢占的发生。真正的抢占还需要一个特定的时机让正在运行中的进程有机会调用一下 __schele()函数,发起真正的调度。
实际上会调用__schele()函数共有以下几个时机
从系统调用返回用户态:以64位为例,系统调用的链路为do_syscall_64->syscall_return_slowpath->prepare_exit_to_usermode->exit_to_usermode_loop。在exit_to_usermode_loop中,会检测是否为_TIF_NEED_RESCHED,如果是则调用__schele()
内核态启动:内核态的执行中,被抢占的时机一般发生在 preempt_enable() 中。在内核态的执行中,有的操作是不能被中断的,所以在进行这些操作之前,总是先调用 preempt_disable() 关闭抢占,当再次打开的时候,就是一次内核态代码被抢占的机会。preempt_enable() 会调用 preempt_count_dec_and_test(),判断 preempt_count 和 TIF_NEED_RESCHED 是否可以被抢占。如果可以,就调用 preempt_schele->preempt_schele_common->__schele 进行调度。
本文分析了任务调度的策略、结构体以及整个调度流程,其中关于内存上下文切换的部分尚未详细叙述,留待内存部分展开剖析。
1、调度相关结构体及函数实现
2、schele核心函数
E. Linux的线程库为什么是放在glibc中来实现
线手前粗程的实现有user level thread,kernel-level thread和两者结合的方式。Linux的实现了kernel-level thread,共享进程地址空间的进程相当于线程。
PThread是user level thread,内核根本不了解其线悔则程的存在,不会对其中的线程进行调度,更不会对其进行代码实现毕镇。
F. 进程调度的Linux 原理
1,SCHED_OTHER 分时调度策略,
2,SCHED_FIFO实时调度策略,先到先服务
3,SCHED_RR实时调度策略,时间片轮转
实时进程将得到优先调用,实时进程根据实时优先级决定调度权值,分时进程则通过nice和counter值决定权值,nice越小,counter越大,被调度的概率越大,也就是曾经使用了cpu最少的进程将会得到优先调度。
SHCED_RR和SCHED_FIFO的不同:
当采用SHCED_RR策略的进程的时间片用完,系统将重新分配时间片,并置于就绪队列尾。放在队列尾保证了所有具有相同优先级的RR任务的调度公平。
SCHED_FIFO一旦占用cpu则一直运行。一直运行直到有更高优先级任务到达或自己放弃。
如果有相同优先级的实时进程(根据优先级计算的调度权值是一样的)已经准备好,FIFO时必须等待该进程主动放弃后才可以运行这个优先级相同的任务。而RR可以让每个任务都执行一段时间。
相同点:
RR和FIFO都只用于实时任务。
创建时优先级大于0(1-99)。
按照可抢占优先级调度算法进行。
就绪态的实时任务立即抢占非实时任务。
所有任务都采用linux分时调度策略时。
1,创建任务指定采用分时调度策略,并指定优先级nice值(-20~19)。
2,将根据每个任务的nice值确定在cpu上的执行时间(counter)。
3,如果没有等待资源,则将该任务加入到就绪队列中。
4,调度程序遍历就绪队列中的任务,通过对每个任务动态优先级的计算(counter+20-nice)结果,选择计算结果最大的一个去运行,当这个时间片用完后(counter减至0)或者主动放弃cpu时,该任务将被放在就绪队列末尾(时间片用完)或等待队列(因等待资源而放弃cpu)中。
5,此时调度程序重复上面计算过程,转到第4步。
6,当调度程序发现所有就绪任务计算所得的权值都为不大于0时,重复第2步。
所有任务都采用FIFO时,
1,创建进程时指定采用FIFO,并设置实时优先级rt_priority(1-99)。
2,如果没有等待资源,则将该任务加入到就绪队列中。
3,调度程序遍历就绪队列,根据实时优先级计算调度权值(1000+rt_priority),选择权值最高的任务使用cpu,该FIFO任务将一直占有cpu直到有优先级更高的任务就绪(即使优先级相同也不行)或者主动放弃(等待资源)。
4,调度程序发现有优先级更高的任务到达(高优先级任务可能被中断或定时器任务唤醒,再或被当前运行的任务唤醒,等等),则调度程序立即在当前任务堆栈中保存当前cpu寄存器的所有数据,重新从高优先级任务的堆栈中加载寄存器数据到cpu,此时高优先级的任务开始运行。重复第3步。
5,如果当前任务因等待资源而主动放弃cpu使用权,则该任务将从就绪队列中删除,加入等待队列,此时重复第3步。
所有任务都采用RR调度策略时
1,创建任务时指定调度参数为RR,并设置任务的实时优先级和nice值(nice值将会转换为该任务的时间片的长度)。
2,如果没有等待资源,则将该任务加入到就绪队列中。
3,调度程序遍历就绪队列,根据实时优先级计算调度权值(1000+rt_priority),选择权值最高的任务使用cpu。
4,如果就绪队列中的RR任务时间片为0,则会根据nice值设置该任务的时间片,同时将该任务放入就绪队列的末尾。重复步骤3。
5,当前任务由于等待资源而主动退出cpu,则其加入等待队列中。重复步骤3。
系统中既有分时调度,又有时间片轮转调度和先进先出调度
1,RR调度和FIFO调度的进程属于实时进程,以分时调度的进程是非实时进程。
2,当实时进程准备就绪后,如果当前cpu正在运行非实时进程,则实时进程立即抢占非实时进程。
3,RR进程和FIFO进程都采用实时优先级做为调度的权值标准,RR是FIFO的一个延伸。FIFO时,如果两个进程的优先级一样,则这两个优先级一样的进程具体执行哪一个是由其在队列中的未知决定的,这样导致一些不公正性(优先级是一样的,为什么要让你一直运行?),如果将两个优先级一样的任务的调度策略都设为RR,则保证了这两个任务可以循环执行,保证了公平。 调度程序运行时,要在所有处于可运行状态的进程之中选择最值得运行的进程投入运行。选择进程的依据是什么呢?在每个进程的task_struct 结构中有这么四项:
policy, priority , counter, rt_priority
这四项就是调度程序选择进程的依据.其中,policy是进程的调度策略,用来区分两种进程-实时和普通;priority是进程(实时和普通)的优先级;counter 是进程剩余的时间片,它的大小完全由priority决定;rt_priority是实时优先级,这是实时进程所特有的,用于实时进程间的选择。
首先,Linux 根据policy从整体上区分实时进程和普通进程,因为实时进程和普通进程度调度是不同的,它们两者之间,实时进程应该先于普通进程而运行,然后,对于同一类型的不同进程,采用不同的标准来选择进程:
对于普通进程,Linux采用动态优先调度,选择进程的依据就是进程counter的大小。进程创建时,优先级priority被赋一个初值,一般为0~70之间的数字,这个数字同时也是计数器counter的初值,就是说进程创建时两者是相等的。字面上看,priority是“优先级”、counter是“计数器”的意思,然而实际上,它们表达的是同一个意思-进程的“时间片”。Priority代表分配给该进程的时间片,counter表示该进程剩余的时间片。在进程运行过程中,counter不断减少,而priority保持不变,以便在counter变为0的时候(该进程用完了所分配的时间片)对counter重新赋值。当一个普通进程的时间片用完以后,并不马上用priority对counter进行赋值,只有所有处于可运行状态的普通进程的时间片(p->;;counter==0)都用完了以后,才用priority对counter重新赋值,这个普通进程才有了再次被调度的机会。这说明,普通进程运行过程中,counter的减小给了其它进程得以运行的机会,直至counter减为0时才完全放弃对CPU的使用,这就相对于优先级在动态变化,所以称之为动态优先调度。至于时间片这个概念,和其他不同操作系统一样的,Linux的时间单位也是“时钟滴答”,只是不同操作系统对一个时钟滴答的定义不同而已(Linux为10ms)。进程的时间片就是指多少个时钟滴答,比如,若priority为20,则分配给该进程的时间片就为20个时钟滴答,也就是20*10ms=200ms。Linux中某个进程的调度策略(policy)、优先级(priority)等可以作为参数由用户自己决定,具有相当的灵活性。内核创建新进程时分配给进程的时间片缺省为200ms(更准确的,应为210ms),用户可以通过系统调用改变它。
对于实时进程,Linux采用了两种调度策略,即FIFO(先来先服务调度)和RR(时间片轮转调度)。因为实时进程具有一定程度的紧迫性,所以衡量一个实时进程是否应该运行,Linux采用了一个比较固定的标准。实时进程的counter只是用来表示该进程的剩余时间片,并不作为衡量它是否值得运行的标准,这和普通进程是有区别的。上面已经看到,每个进程有两个优先级,实时优先级就是用来衡量实时进程是否值得运行的。
这一切看来比较麻烦,但实际上Linux中的实现相当简单。Linux用函数goodness()来衡量一个处于可运行状态的进程值得运行的程度。该函数综合了上面提到的各个方面,给每个处于可运行状态的进程赋予一个权值(weight),调度程序以这个权值作为选择进程的唯一依据。
Linux根据policy的值将进程总体上分为实时进程和普通进程,提供了三种调度算法:一种传统的Unix调度程序和两个由POSIX.1b(原名为POSIX.4)操作系统标准所规定的“实时”调度程序。但这种实时只是软实时,不满足诸如中断等待时间等硬实时要求,只是保证了当实时进程需要时一定只把CPU分配给实时进程。
非实时进程有两种优先级,一种是静态优先级,另一种是动态优先级。实时进程又增加了第三种优先级,实时优先级。优先级是一些简单的整数,为了决定应该允许哪一个进程使用CPU的资源,用优先级代表相对权值-优先级越高,它得到CPU时间的机会也就越大。
? 静态优先级(priority)-不随时间而改变,只能由用户进行修改。它指明了在被迫和其他进程竞争CPU之前,该进程所应该被允许的时间片的最大值(但很可能的,在该时间片耗尽之前,进程就被迫交出了CPU)。
? 动态优先级(counter)-只要进程拥有CPU,它就随着时间不断减小;当它小于0时,标记进程重新调度。它指明了在这个时间片中所剩余的时间量。
? 实时优先级(rt_priority)-指明这个进程自动把CPU交给哪一个其他进程;较高权值的进程总是优先于较低权值的进程。如果一个进程不是实时进程,其优先级就是0,所以实时进程总是优先于非实时进程的(但实际上,实时进程也会主动放弃CPU)。
当policy分别为以下值时:
1) SCHED_OTHER:这是普通的用户进程,进程的缺省类型,采用动态优先调度策略,选择进程的依据主要是根据进程goodness值的大小。这种进程在运行时,可以被高goodness值的进程抢先。
2) SCHED_FIFO:这是一种实时进程,遵守POSIX1.b标准的FIFO(先入先出)调度规则。它会一直运行,直到有一个进程因I/O阻塞,或者主动释放CPU,或者是CPU被另一个具有更高rt_priority的实时进程抢先。在Linux实现中,SCHED_FIFO进程仍然拥有时间片-只有当时间片用完时它们才被迫释放CPU。因此,如同POSIX1.b一样,这样的进程就象没有时间片(不是采用分时)一样运行。Linux中进程仍然保持对其时间片的记录(不修改counter)主要是为了实现的方便,同时避免在调度代码的关键路径上出现条件判断语句 if (!(current->;;policy&;;SCHED_FIFO)){...}-要知道,其他大量非FIFO进程都需要记录时间片,这种多余的检测只会浪费CPU资源。(一种优化措施,不该将执行时间占10%的代码的运行时间减少到50%;而是将执行时间占90%的代码的运行时间减少到95%。0.9+0.1*0.5=0.95>;;0.1+0.9*0.9=0.91)
3) SCHED_RR:这也是一种实时进程,遵守POSIX1.b标准的RR(循环round-robin)调度规则。除了时间片有些不同外,这种策略与SCHED_FIFO类似。当SCHED_RR进程的时间片用完后,就被放到SCHED_FIFO和SCHED_RR队列的末尾。
只要系统中有一个实时进程在运行,则任何SCHED_OTHER进程都不能在任何CPU运行。每个实时进程有一个rt_priority,因此,可以按照rt_priority在所有SCHED_RR进程之间分配CPU。其作用与SCHED_OTHER进程的priority作用一样。只有root用户能够用系统调用sched_setscheler,来改变当前进程的类型(sys_nice,sys_setpriority)。
此外,内核还定义了SCHED_YIELD,这并不是一种调度策略,而是截取调度策略的一个附加位。如同前面说明的一样,如果有其他进程需要CPU,它就提示调度程序释放CPU。特别要注意的就是这甚至会引起实时进程把CPU释放给非实时进程。 真正执行调度的函数是schele(void),它选择一个最合适的进程执行,并且真正进行上下文切换,使得选中的进程得以执行。而reschele_idle(struct task_struct *p)的作用是为进程选择一个合适的CPU来执行,如果它选中了某个CPU,则将该CPU上当前运行进程的need_resched标志置为1,然后向它发出一个重新调度的处理机间中断,使得选中的CPU能够在中断处理返回时执行schele函数,真正调度进程p在CPU上执行。在schele()和reschele_idle()中调用了goodness()函数。goodness()函数用来衡量一个处于可运行状态的进程值得运行的程度。此外,在schele()函数中还调用了schele_tail()函数;在reschele_idle()函数中还调用了reschele_idle_slow()。这些函数的实现对理解SMP的调度非常重要,下面一一分析这些函数。先给出每个函数的主要流程图,然后给出源代码,并加注释。
goodness()函数分析
goodness()函数计算一个处于可运行状态的进程值得运行的程度。一个任务的goodness是以下因素的函数:正在运行的任务、想要运行的任务、当前的CPU。goodness返回下面两类值中的一个:1000以下或者1000以上。1000或者1000以上的值只能赋给“实时”进程,从0到999的值只能赋给普通进程。实际上,在单处理器情况下,普通进程的goodness值只使用这个范围底部的一部分,从0到41。在SMP情况下,SMP模式会优先照顾等待同一个处理器的进程。不过,不管是UP还是SMP,实时进程的goodness值的范围是从1001到1099。
goodness()函数其实是不会返回-1000的,也不会返回其他负值。由于idle进程的counter值为负,所以如果使用idle进程作为参数调用goodness,就会返回负值,但这是不会发生的。
goodness()是个简单的函数,但是它是linux调度程序不可缺少的部分。运行队列中的每个进程每次执行schele时都要调度它,因此它的执行速度必须很快。
//在/kernel/sched.c中
static inline int goodness(struct task_struct * p, int this_cpu, struct mm_struct *this_mm)
{ int weight;
if (p->;;policy != SCHED_OTHER) {/*如果是实时进程,则*/
weight = 1000 + p->;;rt_priority;
goto out;
}
/* 将counter的值赋给weight,这就给了进程一个大概的权值,counter中的值表示进程在一个时间片内,剩下要运行的时间.*/
weight = p->;;counter;
if (!weight) /* weight==0,表示该进程的时间片已经用完,则直接转到标号out*/
goto out;
#ifdef __SMP__
/*在SMP情况下,如果进程将要运行的CPU与进程上次运行的CPU是一样的,则最有利,因此,假如进程上次运行的CPU与当前CPU一致的话,权值加上PROC_CHANGE_PENALTY,这个宏定义为20。*/
if (p->;;processor == this_cpu)
weight += PROC_CHANGE_PENALTY;
#endif
if (p->;;mm == this_mm) /*进程p与当前运行进程,是同一个进程的不同线程,或者是共享地址空间的不同进程,优先选择,权值加1*/
weight += 1;
weight += p->;;priority; /* 权值加上进程的优先级*/
out:
return weight; /* 返回值作为进程调度的唯一依据,谁的权值大,就调度谁运行*/
}
schele()函数分析
schele()函数的作用是,选择一个合适的进程在CPU上执行,它仅仅根据'goodness'来工作。对于SMP情况,除了计算每个进程的加权平均运行时间外,其他与SMP相关的部分主要由goodness()函数来体现。
流程:
①将prev和next设置为schele最感兴趣的两个进程:其中一个是在调用schele时正在运行的进程(prev),另外一个应该是接着就给予CPU的进程(next)。注意:prev和next可能是相同的-schele可以重新调度已经获得cpu的进程.
②中断处理程序运行“下半部分”.
③内核实时系统部分的实现,循环调度程序(SCHED_RR)通过移动“耗尽的”RR进程-已经用完其时间片的进程-到队列末尾,这样具有相同优先级的其他RR进程就可以获得CPU了。同时,这补充了耗尽进程的时间片。
④由于代码的其他部分已经决定了进程必须被移进或移出TASK_RUNNING状态,所以会经常使用schele,例如,如果进程正在等待的硬件条件已经发生,所以如果必要,这个switch会改变进程的状态。如果进程已经处于TASK_RUNNING状态,它就无需处理了。如果它是可以中断的(等待信号),并且信号已经到达了进程,就返回TASK_RUNNING状态。在所以其他情况下(例如,进程已经处于TASK_UNINTERRUPTIBLE状态了),应该从运行队列中将进程移走。
⑤将p初始化为运行队列的第一个任务;p会遍历队列中的所有任务。
⑥c记录了运行队列中所有进程最好的“goodness”-具有最好“goodness”的进程是最易获得CPU的进程。goodness的值越高越好。
⑦遍历执行任务链表,跟踪具有最好goodness的进程。
⑧这个循环中只考虑了唯一一个可以调度的进程。在SMP模式下,只有任务不在cpu上运行时,即can_schele宏返回为真时,才会考虑该任务。在UP情况下,can_schele宏返回恒为真.
⑨如果循环结束后,得到c的值为0。说明运行队列中的所有进程的goodness值都为0。goodness的值为0,意味着进程已经用完它的时间片,或者它已经明确说明要释放CPU。在这种情况下,schele要重新计算进程的counter;新counter的值是原来值的一半加上进程的静态优先级(priortiy),除非进程已经释放CPU,否则原来counter的值为0。因此,schele通常只是把counter初始化为静态优先级。(中断处理程序和由另一个处理器引起的分支在schele搜寻goodness最大值时都将增加此循环中的计数器,因此由于这个原因计数器可能不会为0。显然,这很罕见。)在counter的值计算完成后,重新开始执行这个循环,找具有最大goodness的任务。
⑩如果schele已经选择了一个不同于前面正在执行的进程来调度,那么就必须挂起原来的进程并允许新的进程运行。这时调用switch_to来进行切换。
G. “图文结合”Linux 进程、线程、文件描述符的底层原理
开发十年经验总结,阿里架构师的手写Spring boot原理实践文档
阿里架构师的这份:Redis核心原理与应用实践,带你手撕Redis
Tomcat结构原理详解
说到进程,恐怕面试中最常见的问题就是线程和进程的关系了,那么先说一下答案: 在 Linux 系统中,进程和线程几乎没有区别 。
Linux 中的进程其实就是一个数据结构,顺带可以理解文件描述符、重定向、管道命令的底层工作原理,最后我们从操作系统的角度看看为什么说线程和进程基本没有区别。
首先,抽象地来说,我们的计算机就是这个东西:
这个大的矩形表示计算机的 内存空间 ,其中的小矩形代表 进程 ,左下角的圆形表示 磁盘 ,右下角的图形表示一些 输入输出设备 ,比如鼠标键盘显示器等等。另外,注意到内存空间被划分为了两块,上半部分表示 用户空间 ,下半部分表示 内核空间 。
用户空间装着用户进程需要使用的资源,比如你在程序代码里开一个数组,这个数组肯定存在用户空间;内核空间存放内核进程需要加载的系统资源,这一些资源一般是不允许用户访问的。但是注意有的用户进程会共享一些内核空间的资源,比如一些动态链接库等等。
我们用 C 语言写一个 hello 程序,编译后得到一个可执行文件,在命令行运行就可以打印出一句 hello world,然后程序退出。在操作系统层面,就是新建了一个进程,这个进程将我们编译出来的可执行文件读入内存空间,然后执行,最后退出。
你编译好的那个可执行程序只是一个文件,不是进程,可执行文件必须要载入内存,包装成一个进程才能真正跑起来。进程是要依靠操作系统创建的,每个进程都有它的固有属性,比如进程号(PID)、进程状态、打开的文件等等,进程创建好之后,读入你的程序,你的程序才被系统执行。
那么,操作系统是如何创建进程的呢? 对于操作系统,进程就是一个数据结构 ,我们直接来看 Linux 的源码:
task_struct 就是 Linux 内核对于一个进程的描述,也可以称为“进程描述符”。源码比较复杂,我这里就截取了一小部分比较常见的。
我们主要聊聊 mm 指针和 files 指针。 mm 指向的是进程的虚拟内存,也就是载入资源和可执行文件的地方; files 指针指向一个数组,这个数组里装着所有该进程打开的文件的指针。
先说 files ,它是一个文件指针数组。一般来说,一个进程会从 files[0] 读取输入,将输出写入 files[1] ,将错误信息写入 files[2] 。
举个例子,以我们的角度 C 语言的 printf 函数是向命令行打印字符,但是从进程的角度来看,就是向 files[1] 写入数据;同理, scanf 函数就是进程试图从 files[0] 这个文件中读取数据。
每个进程被创建时, files 的前三位被填入默认值,分别指向标准输入流、标准输出流、标准错误流。我们常说的“文件描述符”就是指这个文件指针数组的索引 ,所以程序的文件描述符默认情况下 0 是输入,1 是输出,2 是错误。
我们可以重新画一幅图:
对于一般的计算机,输入流是键盘,输出流是显示器,错误流也是显示器,所以现在这个进程和内核连了三根线。因为硬件都是由内核管理的,我们的进程需要通过“系统调用”让内核进程访问硬件资源。
PS:不要忘了,Linux 中一切都被抽象成文件,设备也是文件,可以进行读和写。
如果我们写的程序需要其他资源,比如打开一个文件进行读写,这也很简单,进行系统调用,让内核把文件打开,这个文件就会被放到 files 的第 4 个位置,对应文件描述符 3:
明白了这个原理, 输入重定向 就很好理解了,程序想读取数据的时候就会去 files[0] 读取,所以我们只要把 files[0] 指向一个文件,那么程序就会从这个文件中读取数据,而不是从键盘:
同理, 输出重定向 就是把 files[1] 指向一个文件,那么程序的输出就不会写入到显示器,而是写入到这个文件中:
错误重定向也是一样的,就不再赘述。
管道符其实也是异曲同工,把一个进程的输出流和另一个进程的输入流接起一条“管道”,数据就在其中传递,不得不说这种设计思想真的很巧妙:
到这里,你可能也看出“Linux 中一切皆文件”设计思路的高明了,不管是设备、另一个进程、socket 套接字还是真正的文件,全部都可以读写,统一装进一个简单的 files 数组,进程通过简单的文件描述符访问相应资源,具体细节交于操作系统,有效解耦,优美高效。
首先要明确的是,多进程和多线程都是并发,都可以提高处理器的利用效率,所以现在的关键是,多线程和多进程有啥区别。
为什么说 Linux 中线程和进程基本没有区别呢,因为从 Linux 内核的角度来看,并没有把线程和进程区别对待。
我们知道系统调用 fork() 可以新建一个子进程,函数 pthread() 可以新建一个线程。 但无论线程还是进程,都是用 task_struct 结构表示的,唯一的区别就是共享的数据区域不同 。
换句话说,线程看起来跟进程没有区别,只是线程的某些数据区域和其父进程是共享的,而子进程是拷贝副本,而不是共享。就比如说, mm 结构和 files 结构在线程中都是共享的,我画两张图你就明白了:
所以说,我们的多线程程序要利用锁机制,避免多个线程同时往同一区域写入数据,否则可能造成数据错乱。
那么你可能问, 既然进程和线程差不多,而且多进程数据不共享,即不存在数据错乱的问题,为什么多线程的使用比多进程普遍得多呢 ?
因为现实中数据共享的并发更普遍呀,比如十个人同时从一个账户取十元,我们希望的是这个共享账户的余额正确减少一百元,而不是希望每人获得一个账户的拷贝,每个拷贝账户减少十元。
当然,必须要说明的是, 只有 Linux 系统将线程看做共享数据的进程 ,不对其做特殊看待 ,其他的很多操作系统是对线程和进程区别对待的,线程有其特有的数据结构,我个人认为不如 Linux 的这种设计简洁,增加了系统的复杂度。
在 Linux 中新建线程和进程的效率都是很高的,对于新建进程时内存区域拷贝的问题,Linux 采用了 -on-write 的策略优化,也就是并不真正复制父进程的内存空间,而是等到需要写操作时才去复制。 所以 Linux 中新建进程和新建线程都是很迅速的 。
H. 关于linux下多线程编程
pthread_join 线程停止等待函数没有调用
pthread_create 线程生成后,没有等子线程停止,主线程就先停止了。
主线程停止后,整个程序停止,子线程在没有printf的时候就被结束了。
结论:不是你没有看到结果,而是在子线程printf("..................\n");之前整个程序就已经停止了。
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#define FALSE -1
#define TRUE 0
void *shuchu( void *my )
{
int j;
printf("..................\n");
}
int main()
{
int i = 0;
int rc = 0;
int ret1;
pthread_t p_thread1;
if(0!=(ret1 = pthread_create(&p_thread1, NULL, shuchu, NULL)))printf("sfdfsdfi\n");
printf("[%d]\n",p_thread1);
pthread_join(p_thread1, NULL);
return TRUE;
}
I. linuxsleep(0)一定线程切换
Linuxsleep(0)是Linux内核中的一个系统调用,它可以让线程在指定的时尘梁间内进入睡眠状态,从而实现线程切换。它可以用来实现定时器功能,也可以用来实现线程同步。它的原理是,当线程调用linuxsleep(0)时,它会把自己放入睡眠队列,并且把当前的CPU时间片设置为0,这样就可以实现线程切换。当睡眠时间到达时,线程会被唤醒,然后继此昌续执行。派扒运因此,linuxsleep(0)可以用来实现线程切换,从而提高系统的性能。
J. Linux下C/C++ 手写一个线程池-
在我们日常生活中会遇到许许多多的问题,如果一个服务端要接受很多客户端的数据,该怎么办?多线程并发内存不够怎么办?所以我们需要了解线程池的相关知识。
1.线程池的简介
线程池是一种多线程处理形式,处理过程中将任务添加到队列,然后在创建线程后自动启动这些任务。线程池线程都是后台线程。每个线程都使用默认的堆栈大小,以默认的优先级运行,并处于多线程单元中。如果某个线程在托管代码中空闲(如正在等待某个事件),则线程池将插入另一个辅助线程来使所有处理器保持繁忙。如果所有线程池线程都始终保持繁忙,但队列中包含挂起的工作,则线程池将在一段时间后创建另一个辅助线程但线程的数目永远不会超过最大值。超过最大值的线程可以排队,但他们要等到其他线程完成后才启动。
2.线程池的组成
1、线程池管理器(ThreadPoolManager):用于创建并管理线程池
2、工作线程(WorkThread): 线程池中线程
3、任务接口(Task):每个任务必须实现的接口,以供工作线程调度任务的执行。
4、任务队列:用于存放没有处理的任务。提供一种缓冲机制。
3.线程池的主要优点
1.避免线程太多,使得内存耗尽
2.避免创建与销毁线程的代价
3.任务与执行分离
1.线程池结构体定义
代码如下(示例):
相关视频推荐
150行代码,带你手写线程池,自行准备linux环境
C++后台开发该学哪些内容,标悔盯准技术路线及面经与算法该如何刷
学习地址:C/C++Linux服务器开发/后台架构师【零好陆声教友前顷育】-学习视频教程-腾讯课堂
需要更多C/C++ Linux服务器架构师学习资料加qun 812855908 (资料包括C/C++,Linux,golang技术,内核,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg,大厂面试题 等)
2.接口定义
代码如下(示例):
3.回调函数
代码如下(示例):
4.全部代码(加注释)
代码如下(示例):
关于线程池是基本代码就在上面了,关于编程这一部分内容,我建议大家还是要自己去动手实现,如果只是单纯的看了一遍,知识这块可能会记住,但是操作起来可能就比较吃力,万事开头难,只要坚持下去,总会有结果的。