线程调度和IO多路复用杂谈

做网络编程的话,经常会听到什么epoll(linux下)、kqueue(bsd和苹果下)、io完成端口(windows下)这样的名词,也许看了网上的资料也知道用法是什么样的,但是却对为什么这样做没有很感性的认识。我在尝试设计一个实验能够从线程调度的角度来对这个事情有直观认识,现在勉强也能算是搞出来了,所以写一篇讲讲是做了什么。

1. 线程

线程的事情要从CPU开始说起。CPU可以执行程序代码,我拿C语言写的计算两个数的和的程序举例子:

int a, b;
scanf("%d%d", &a, &b);
printf("%d", a + b);

这个程序学过C语言的人大体上是都写过的。不说太复杂的,我省略掉细节说,那么就是CPU会一行一行代码执行下来:首先给变量 a 和 b 分配空间,然后调用 scanf 从键盘获取输入的数字,然后计算两个数字的和,并且显示出来。

但是这么一台电脑,只拿来干这种事情,实在是很不合算。CPU那么快,分配完变量 a 和 b 的空间以后就坐那边等,等你输入完数字再瞬间算完显示出来,非常非常大量的时间都花在“等”上面了。这么用电脑,实在是很浪费。不应该不应该。

然后现在要说到多线程。书本上说到多线程的时候,时常爱拿出来说的一个例子是你可以拿电脑一边听歌一边写报告。现在还是拿个简化的模型来考虑,考虑听歌的时候电脑在干什么,考虑写报告的时候电脑在干什么。
对于听歌来说,电脑做的事情其实很简单:从硬盘中把MP3读出来,解码成声卡能识别的格式,把数据传到声卡上;这一小段放完以后,重复执行这个步骤,传下一个小段。因为电脑读MP3并且解码的速度是远远大于声卡播放声音的速度的(播放快了还能听吗?),所以电脑大部分时间还是空闲的。
再说写报告,如果你没有在往里面打字的话,电脑在干啥?电脑在算时间,时间差不多到了把输入光标隐藏起来,等时间差不多到了再把输入光标显示出来,然后你就看到光标在闪:显示和隐藏光标其实根本花不了多少时间,电脑大部分时间花在“等”上面。那么你在打字的时候呢?就算一分钟能敲300个按键,每个按键平均也就20毫秒,输入法对一个按键的反应时间不要那么久,结果大部分时间还是在等你按键。
现在把三件事情合起来,电脑需要等声卡播放完、需要等时间到了去闪光标、需要等你按键输入,全都在等。所以它在等声卡播放完之前可以处理你按键的事情,可以处理光标闪的事情,电脑可以同时干很多事。

2. 线程调度

电脑虽然能像上面说的那样同时干很多事,但是电脑系统并不能完全按照这样做。一个写报告的软件,在你也不输入光标也不闪的时候可以告诉系统“我现在没有要做的事情了”,但是如果开发软件的人做得不好,忘记告诉了或者怎么的,这电脑可能就卡死在这里面没有机会干其他事了。所以系统不能这样干,比如你现在要在CPU上执行程序,系统会分配给你一小段时间,你可以用CPU这么一段时间,如果时间到了你还没告诉系统“我现在暂时干完事了”,系统也会强行不让你继续运行,它会把你从CPU里拽出来,然后放另一个要用CPU的进去;只有说找不到什么程序要用CPU了,才会让你继续用。

这种会把你拽出来的方式叫做“抢占式多线程”;而这种你可以抱着CPU一直用到你觉得暂时用完了,比如上面算加法的C程序,执行到scanf的时候用户没输入,那也就是“暂时用完了”:要等用户输入了才能继续用CPU,这样的使用方式,叫做非抢占式多线程。Windows、Linux或者苹果这样的系统,都用的抢占式多线程的方式,这样才不会因为有一个程序写得不太好,抱着CPU不放,导致整个系统都没法用。

3. 线程切换

系统要强行把一个正在执行的程序从CPU里拖出来,就也要保证下次把这个程序再放CPU里去的时候它能继续这么执行。系统在把程序从CPU里拖出来的时候会保存程序的一系列信息,比如执行到哪里了、是通过调用了多少函数执行到这个位置的、执行到这边的时候各种变量的值是多少、算一半的那些东西放在寄存器里的值是多少,这类。这些东西都保存好之后,就可以把另一个程序的这些东西进CPU,这样就做到了“继续执行另一个程序”。然后等到了时间或者程序主动表示“这一段执行完了现在没事干了”,再把程序从CPU里弄出来。
在系统寻找下一个要放到CPU上执行的程序的时候,这种表示“没事干”的程序是不会被考虑的:都没事干了放进去干嘛。当然这种没事干的程序也能变成有事干,一个输入法你不按键盘的时候它没事干,你按键盘了它就有事干了;一个写文章的软件,光标显示着的时候和光标隐藏着的时候都是没事干,要从隐藏变成显示、显示变成隐藏的时候软件就有事干;一个播放音乐的软件,声卡在把程序送入的声音放完之前,播放器没事干,这段播完了的时候程序就有事干了:要送下一段进去。一个程序的状态会在这种有事干没事干之间变化。

这里上下文里提到的“程序”指的是线程。线条一样执行下来的程序。

线程的状态分为 新建、就绪、阻塞、运行、死亡 这么几个状态。重点关注就绪阻塞和运行:就绪,代表这个线程需要放到CPU上运行,比如事情干一半被操作系统强行从CPU拖出来的,就会是这种状态:它下次还要继续运行才行;阻塞,就像刚才说的,一个播放器,已经把要播放的一小段送到声卡那边去了,在声卡播放完这段之前没事干,没事干就是阻塞状态,等声卡播完这一段了它又会变成就绪状态,否则你听完这一小段音乐就听不到下一段了。运行,就是线程塞到CPU里运行了。

4. 模拟多线程网络服务器的线程切换开销

前面的基础概念说完了,现在要讲讲网络服务器这边是一种什么样的情况。这里以最简单的能架静态网站的HTTP服务器为例子了。

这样的HTTP服务器要做的事情,其实很简单地就只包含这么几个:接受用户的链接,接收用户发来的请求,发送用户请求的文件,关闭连接。但是由于网络传输是很慢的,服务器并不能处理完一个人再处理下一个人:万一遇上一个网络状态不太好的人,光是收请求和发文件就花了特别多的时间,甚至突然对方网络就断了,只能等超时,就会变成后面排了一堆要上你这个网站的人卡在这里。

然后就有了这样一种简单的做法,来一个用户连上来就开一个新线程,专门接收这个人发来的请求,专门给这个人发他要的文件。这样的话,连的人多了,系统里就会有一堆这样的线程,这样的线程执行的套路大概就是执行了一段代码,比如要分析用户的请求数据了,这个时候就会调用例如 recv 这样的函数获取用户发来的数据,但是实际上此时用户没有数据发来,那么这个线程就变成了“没事干”的状态,系统就把这个线程从CPU拖出来、塞别的进去运行了,直到通过网络收到了用户发来的请求,才会变回就绪状态。

现在模拟一下线程这样的行为,为了方便计时,我忽略了网络传输所需的时间,就只模拟这个“被从CPU里拖出来”的动作。然后一个线程干的事情其实就两个:一个是执行代码,这里用求三个数的平均数模拟;另一个就是“被从CPU里拖出来”。拖出来用的是 std::this_thread::yield(); 这种方法。我要等所有线程都创建完了才开始执行和计时,所以用了Barrier:这是一个能让指定数量的线程同时到达这个“栅栏”之后才能继续执行的机制

SYNCHRONIZATION_BARRIER barrier;
void ThreadFunc()
{
    EnterSynchronizationBarrier(&barrier, 0);
    std::vector<int> input(256 * 1024);
    std::vector<int> output(256 * 1024);

    for (int rep = 0; rep < 1000; ++rep)
    {
        for (size_t i = 0; i < input.size() - 2; ++i)
            output[i] = (input[i] + input[i + 1] + input[i + 2]) / 3;
        std::this_thread::yield();
    }
}

然后我现在开200个这样的线程让它跑:

int main()
{
    InitializeSynchronizationBarrier(&barrier, 201, -1);
    std::vector<std::thread> threads(200);

    for (size_t i = 0; i < threads.size(); ++i)
        threads[i] = std::thread(ThreadFunc);
    EnterSynchronizationBarrier(&barrier, 0);
    DeleteSynchronizationBarrier(&barrier);
    time_t beginTime = time(NULL);
    for (size_t i = 0; i < threads.size(); ++i)
        threads[i].join();
    std::cout << (time(NULL) - beginTime) << std::endl;
}

加上所需的头文件,整个程序是这样。这个程序主要是演示线程切换会花多少时间。使用后面提到的IO多路复用技术并不完全是因为节省线程切换的开销的,这里先不谈其他的影响因素,一个个理解会简单一些。可以直接复制去编译自己运行试试

#include <Windows.h>
#include <thread>
#include <vector>
#include <iostream>
#include <time.h>

SYNCHRONIZATION_BARRIER barrier;
void ThreadFunc()
{
    std::vector<int> input(256 * 1024);
    std::vector<int> output(256 * 1024);
    EnterSynchronizationBarrier(&barrier, 0);
    for (int rep = 0; rep < 1000; ++rep)
    {
        for (size_t i = 0; i < input.size() - 2; ++i)
            output[i] = (input[i] + input[i + 1] + input[i + 2]) / 3;
        std::this_thread::yield();
    }
}

int main()
{
    InitializeSynchronizationBarrier(&barrier, 201, -1);
    std::vector<std::thread> threads(200);

    for (size_t i = 0; i < threads.size(); ++i)
        threads[i] = std::thread(ThreadFunc);
    EnterSynchronizationBarrier(&barrier, 0);
    DeleteSynchronizationBarrier(&barrier);
    time_t beginTime = time(NULL);
    for (size_t i = 0; i < threads.size(); ++i)
        threads[i].join();
    std::cout << (time(NULL) - beginTime) << std::endl;
    return 0;
}

它能给出花的总时间。我这边运行以后,花费是 31。

5. 减少线程开销

以上程序代码用 std::this_thread::yield() 来模拟了“要从用户接收数据了,但是数据没来,所以让操作系统把当前线程从CPU抠出来,把CPU让给别人”这样的操作。这里有200个线程在执行,所以系统一直在这么些线程之间切换。我现在换一种方式,让同时运行的线程数正好一个CPU核心一个,这样在调用 yield 的时候,就会发生“系统发现没有其他线程要运行,所以继续让你用CPU”的情况,以此来模拟线程切换的开销小了的时候是什么样的。这里用一个“信号量”来控制同时运行的线程的数量,实际运行效果将是一个CPU核心上一个线程在跑,每次到 yield 的时候系统会看一圈有没有线程要用,没有要用所以就不切换。我直接给出整个程序代码了,和上面相比可以发现多了个 Semaphore(信号量) 而已。信号量是这样一种机制,你可以声明手头有多少个“资源”,这个资源拿走一个少一个,拿不到了就会在那边等;拿到的“人”用完了之后可以还回去,还回去直接就拿去给在那边等的“人”了。这样受到信号量的控制,比如我是4核CPU,那么200个线程里就只会有4个线程能进去执行算平均数和yield(模拟网络收发)这两个操作。那么yield的时候系统就会让你继续执行而没有切换,切换的开销大大减小了。

#include <Windows.h>
#include <thread>
#include <vector>
#include <iostream>
#include <time.h>

SYNCHRONIZATION_BARRIER barrier;
HANDLE semaphore;
void ThreadFunc()
{
    std::vector<int> input(256 * 1024);
    std::vector<int> output(256 * 1024);
    EnterSynchronizationBarrier(&barrier, 0);
    WaitForSingleObject(semaphore, INFINITE);
    for (int rep = 0; rep < 1000; ++rep)
    {
        for (size_t i = 0; i < input.size() - 2; ++i)
            output[i] = (input[i] + input[i + 1] + input[i + 2]) / 3;
        std::this_thread::yield();
    }
    ReleaseSemaphore(semaphore, 1, NULL);
}

int main()
{
    InitializeSynchronizationBarrier(&barrier, 201, -1);
    semaphore = CreateSemaphore(0, std::thread::hardware_concurrency(), std::thread::hardware_concurrency(), 0);
    std::vector<std::thread> threads(200);

    for (size_t i = 0; i < threads.size(); ++i)
        threads[i] = std::thread(ThreadFunc);
    EnterSynchronizationBarrier(&barrier, 0);
    DeleteSynchronizationBarrier(&barrier);
    time_t beginTime = time(NULL);
    for (size_t i = 0; i < threads.size(); ++i)
        threads[i].join();
    std::cout << (time(NULL) - beginTime) << std::endl;
    CloseHandle(semaphore);
    return 0;
}

这个程序在我电脑上运行,最终输出是 21。

6. IO多路复用

从上面程序的对比,可以看出线程切换的开销小了之后,执行同样多的任务,性能要好不少,这个例子在我电脑上是差了差不多10秒钟。

那么回到刚才HTTP服务器的话题,HTTP服务器给一个连上来的用户开一个线程,网络又是一种很慢的输入输出(IO)方式,在调用 send 发送数据和调用 recv 接收数据的时候,会遇到发送缓冲区满了只能等、接收缓冲区空了只能等 这样的情况。因为你要等了,系统就会做线程切换,换到能运行的线程上。等你等到你要的东西了,再让你继续执行。效果就是和上面例子里前一个程序那样,切换线程花了很多时间。

那么 epoll、kqueue 是怎么一回事呢,你把网络通信那个“文件”设置成“非阻塞”模式之后,如果 send 遇到发送缓冲区满了、 recv 遇到接收缓冲区空了,它不会“等”,而是直接告诉你满了空了,这个时候你要做的事情是通过 epoll 或者 kqueue 机制,告诉系统“能发送数据、能接收数据了叫我”,因为线程没有在等,所以你可以接着干其他事,这里指的是处理其他用户的连接。等一波任务处理完之后,就回到“等 epoll / kqueue 叫”这样的阶段,epoll 和 kqueue 发现有用户可以发数据可以收数据了,就叫你,告诉你有多少用户可以收发了,然后你就接着处理这些用户——一切都是在同一个线程内执行,中间不进行切换。这样线程切换的开销就省掉了,就像上面第二个例子那样:yield 的时候不切换、继续让你执行。

还有一个“io完成端口”没说,其实它原理和 epoll、kqueue 差不多,只是它把顺序变了一下:不是可以 send 可以 recv 的时候叫你。调用 send、recv 的时候它不会告诉你缓冲区满了空了,而是确实去执行了,等到发完了、收完了再叫你,而不是可以发、可以收的时候叫你。这个层面上说其实没什么区别……

7. ???

这种“等系统叫你”的方式,会大大增加写出来的代码的复杂度。本来是 接收数据——处理数据 这样的流程,变成了 等系统叫你——接收数据——处理数据。而其中的“等系统叫你”当然不能呆在那边等,这样就和不用IO多路复用没什么两样了。到了“等系统叫你”这样的环节,你要把当前处理的代码 return 掉,然后去处理其他用户,然后等系统叫你的时候,又要重新回去执行对付这个用户的代码,还要回到刚才做一半的事情的状态里去,这个就很恶心:return都return了,这要怎么回。在这里通常是调用了一个新的函数,也就是一个处理代码,要被拆开成好几个不同的函数,就有了局部变量(“也就是状态”)在不同函数之间传递非常的麻烦。有个叫“协程”的技术可以用来解决这样的问题,但这不是这里要说的。协程可以模拟一种“伪return”,让代码的执行流程,可以有“从一个函数return”的效果;然后下次进来的时候能从这个“伪return”的地方继续执行,局部变量这样的状态就还在,写出来的代码也就和没用IO多路复用的时候差不多,大大降低了代码的复杂度。

这里提一下,为什么第二个示例程序不直接去掉 yield ,而是要执行 yield 然后由系统让你继续执行呢。因为对于 epoll、kqueue、iocp 来说,你告诉系统要等有“事件”的时候叫你,也是要时间要开销的,所以我就留在那边模拟这个开销了。而且你4核CPU跑你的程序的4个线程的时候,也不是一直四个线程都在上面的,操作系统还要执行其他程序对吧,比如桌面环境,比如后台的QQ,浏览器里网页上的动画还在动,或者你开在后面的音乐播放器……

《线程调度和IO多路复用杂谈》上有1条评论

发表评论