排队论建模描述排队机理,但某些结论与直觉相悖,并不形象直观,理论如果不能直观解释,只剩下数学推导,并不十分有趣。本文尝试用有趣的方式解释排队。本文虽以数据包为例,但任何可以排队的主体都可作为主语。
所有统计复用系统,均需要buffer用来支持队列,这些系统中,队列是必然存在的。队列的成因有两个:
- 统计波动造成到达与处理时间差。
到达时间不是均匀的,会有突发,带来波动。
服务时间不是均匀的,有长有短,放大波动。 - 处理机无法突破时间墙。
一般用sequence/time坐标系来描述队列的产生和消除:
但这类图中无法看到动力学机理,它要么宏观地描述大趋势,要么显示特定时间点的微观细节,对于深入理解队列背后看不见的东西,并不十分有用。
我并非在贬低sequence/time图,相反,我非常喜欢。实际工作中,wireshark的tcptrace图给过我很大的帮助,对于我而言它已经必不可少。只不过在对于理解队列的本质这个单独的层面,我有比sequence/time图更好的东西。
类似于描述无标度系统选用双对数坐标系,可以对“到达/服务”过程做坐标变换,这里采用双时间坐标系:
- 横轴表示到达时间序列,离散的不均匀坐标点表示到达时间。
- 纵轴表示服务时间序列,离散的不均匀坐标点表示服务时间。
- 坐标系中的点坐标表示数据包到达的时间点和处理的时间点。
在双时间坐标系中, L : T 2 = T 1 L:T_2=T_1 L:T2=T1将平面分为3个区域, L L L本身作为一堵时间墙,意味着 P ( t a r r i v , t s e r v ) P(t_{arriv},t_{serv}) P(tarriv,tserv)组成的处理线只能位于 T 2 ≥ T 1 T_2\geq T_1 T2≥T1区域。处理线是所有离散点连接成的包络线:
这意味着即使平均服务速率比平均到达速率高,由于时间墙,无法提前处理潜在产生队列的数据包,只能在统计波动实际发生,队列实际堆积时,才可处理。这就是队列成因的解释。
现在看一个队列产生和消除的实际例子:
这是一张事后分析图,以7号数据包到达时间点为参考,
P
7
P_7
P7表示其到达和被处理时间,直到时间点
m
m
m,双时间坐标一致,处理线收敛到
T
2
=
T
1
T_2=T_1
T2=T1直线,队列消除。
在上述背景下,可以很容易得出结论:
该结论不假设到达过程以及服务过程的任何分布,这是个普遍的结论,在我看来这是位于M/M/1模型之上的。
当看到排队论M/M/1模型结论时,可能有悖于直觉,为什么
λ
\lambda
λ不能等于
μ
\mu
μ呢?
W
q
=
λ
μ
(
μ
−
λ
)
W_q=\dfrac{\lambda}{\mu(\mu-\lambda)}
Wq=μ(μ−λ)λ
分母不能为0只是数学上的限制,直觉上二者相等是OK的,到达率等于服务率,队列收敛到0。这个看似的悖论在双时间坐标系中却非常显然:
- λ = μ \lambda=\mu λ=μ时靠统计波动清除队列,属于随机游走常返性,不可预期。
- 排队论M/M/1模型结论描述的是稳定行为,而 λ = μ \lambda=\mu λ=μ不是稳定行为。
预备储备就绪,最后我写了个代码仿真排队情况,和排队论教程不同的是,除了指数分布,我还用正态分布做了实验。
仿真逻辑很简单:
- 子线程1循环对全局变量cnt递增1,模拟排队,中间睡眠随机间隔,可按正态分布,指数分布两种方式随机。
- 子线程2循环对全局变量cnt递减1,模拟出队,中间睡眠随机间隔,可按正态分布,指数分布两种方式随机。
- 主线程每隔1秒打印全局变量cnt的值,该值即当前的队列长度。
代码写的不好,但管用:
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <math.h>
#include <float.h>
pthread_mutex_t _mutex, _mutex2;
pthread_cond_t cond1,cond2;
static int cnt = 0, sum1 = 0, sum2 = 0;
static int cnt_add = 0, cnt_dec = 0;
double mean = 0.0, var = 0.0;
double lambda = 0.0;
int type = 0;
int delta = 0;
// Box–Muller
// https://en.wikipedia.org/wiki/Box%E2%80%93Muller_transform#Implementation
double normal(double mean, double var)
{
double epsilon = DBL_EPSILON;
double two_pi = 2*3.141592653589;
double u1, u2;
static double z0, z1;
static unsigned int phase = 0;
phase ++;
if (phase % 2 == 0)
return z1 * var + mean;
do {
u1 = ((double)rand())*(1.0/RAND_MAX);
u2 = ((double)rand())*(1.0/RAND_MAX);
} while (u1 <= epsilon);
z0 = sqrt(-2.0*log(u1)) * cos(two_pi*u2);
z1 = sqrt(-2.0*log(u1)) * sin(two_pi*u2);
return z0 * var + mean;
}
// https://zh.wikipedia.org/wiki/%E6%8C%87%E6%95%B0%E5%88%86%E5%B8%83
double exp(double lambda)
{
double x = 0;
do {
x = (double)(rand() % 1000)/1000;
} while(x == 0);
x = (-1/lambda)*log(1.0 - x);
return x;
}
void *enqueue(void)
{
int secs = 0;
while (1) {
double mic;
pthread_mutex_lock(&_mutex);
cnt ++;
cnt_add ++;
pthread_cond_signal(&cond2);
pthread_mutex_unlock(&_mutex);
if (type == 0) {
mic = normal(mean, var);
if (mic < mean - var) mic = mean - var;
if (mic > mean + var) mic = mean + var;
} else {
mic = exp(1.0/lambda);
}
secs = (int)mic;
usleep(secs);
sum1 += mic;
}
}
void *serve(void)
{
int secs = 0;
while (1) {
double mic;
pthread_mutex_lock(&_mutex);
if (cnt > 0) {
cnt --;
cnt_dec ++;
} else {
pthread_mutex_unlock(&_mutex);
pthread_cond_wait(&cond2,&_mutex2);
continue;
}
pthread_mutex_unlock(&_mutex);
if (type == 0) {
mic = normal(mean, var);
if (mic < mean - var) mic = mean - var;
if (mic > mean + var) mic = mean + var;
} else {
mic = exp((double)1/lambda);
}
if (delta != 0) {
mic = mic - mic/(double)delta;
if (mic < 0) mic = 0;
}
secs = (int)mic;
usleep(secs);
sum2 += mic;
}
}
int main(int argc, char **argv)
{
int ret = 0;
pthread_t id1, id2;
type = atoi(argv[1]);
delta = atoi(argv[2]);
printf("aaaaa:%d\n", delta);
if (type == 0) {
mean = atoi(argv[3]);
var = atoi(argv[4]);
} else {
lambda = atoi(argv[3]);
}
pthread_mutex_init(&_mutex,NULL);
pthread_mutex_init(&_mutex2,NULL);
pthread_create(&id1, NULL, (void*)enqueue, NULL);
pthread_create(&id2, NULL, (void*)serve, NULL);
while (1) {
printf("%d add:%d, dec:%d sum1:%d sum2:%d\n", cnt, cnt_add, cnt_dec, sum1, sum2);
sleep(1);
}
return 0;
}
结果非常有意思:
- 如果不设置任何delta,无论什么分布,cnt均会增加到很大,看上去越来越大,然而却还是可以在某个时间突然变成0或者缓慢变成0,毫无征兆,不可预知,这就是随机游走的常返效应。
- 但凡加一点点delta(数值上大),cnt就会维持在一个不大的值附近,归0的次数会大很多。
- 如果加很大的delta(数值上小),cnt不会比delta小时小得多,偶尔也会很大。
- …
这对设计交换机等设备的队列buffer提供了一些思路,但更是回答了下面的疑问:
- 银行,医院,超市排队时,有好多服务窗口为什么只开不多的几个,为什么眼睁睁看着人们在排长队而无动于衷?
上述结论表明,把服务窗口全部打开对于整体效能是非常不经济的,但对于单独个人,却可以有效减少排队时间,这又是一个trade-off。
新开服务窗口的前提是,减少每个窗口队列的可容纳人数,这可让人们有更多机会选择窗口,平滑单个人服务时间过久引入的队头拥塞(因为人们发现队满了之后不得不选择别的窗口,或者退避,待会儿再来)。这么明显一个道理,在交换机设计者那里,却走向了相反方向。
交换机部署大的buffer是一个不正确的事情,分层网络协议栈引入了数据包出了网卡就是发送成功的假象,但也许只是进了交换机队列,真相是端到端延时增加了。这似乎非常容易理解,如果是人排队,人绝对不会将排队假装成被服务,相反,每个人都讨厌排队!同一个道理换个场景,却被忽略。
从本文的结论,我们可以回答为什么Pacing比Burstiness好。
由于主要讨论数据包转发,而数据包转发处理时延相对均匀,因此将服务台的处理波动消除,假设服务时间是均匀的,在双时间坐标和sequence/time坐标中,列举三种极端:
- Pacing:均匀到达。
- 真实情况:统计到达。
- Burstiness:突发到达。
我们殊途而同归,两个图一模一样:
这也正是M/M/1模型里的结论:
T = 1 μ − λ T=\dfrac{1}{\mu-\lambda} T=μ−λ1
如果做不到完全Pacing,泊松分布统计到达的情况也不错,
λ
\lambda
λ大到一定程度,wait time也不会很大,这意味着队列不会太长,也就不需要安排太多的buffer,然而如果Burstiness,即便
λ
\lambda
λ很小,时延也会很大,这意味着需要更多的buffer。突发导致了bufferbloat。
绳结不解,bufferbloat还是没法解决。
在写一个IDC拥塞控制算法时,无论如何也平滑不了长队列,即便是仿真了绕路算法,依然会快不过突发,Wi-Fi冲突本质上也是一种排队,坐地铁排队,结账排队,菜场一个口碑好的熟肉店买羊脸排队,…到处都在排队…我们的世界是一个分布式统计复用的世界,靠什么拥塞控制都没用,主要还是选路,但端到端原则又不允许选路,那就只有overlay了,这部分太复杂,就先聊聊排队吧,随便写写,就有了本文。
浙江温州皮鞋湿,下雨进水不会胖。