0%

Webbench源码剖析

Webbench源码剖析

项目地址

Webbench介绍

Webbench是一个在linux下使用的非常简单的网站压测工具。它使用fork()模拟多个客户端同时访问我们设定的URL,测试网站在压力下工作的性能,最多可以模拟3万个并发连接去测试网站的负载能力。

而其源码也相当的短,只有500行,读起来非常容易。

不过本项目虽然代码量很小,但是最好熟悉unix网络编程,以及linux下C语言多线程的实现(fork()函数),否则可能看起来会比较吃力。

我们将代码拉下来后,可以发现,其只包含两个文件,我们首先分析代码行数较少的socket.c文件。

socket.c源码剖析

文件说明

这个socket.c文件里仅有一个函数int Socket(const char *host, int clientPort),这是对socket的一层封装,可以解析以字符串传入的url地址端口,并返回一个指向服务器的socket套接字用于连接。

函数工作流程

  1. 首先准备好必须的参数并对其进行初始化。

  2. 先将主机名转换成ip并存储(包括本身就是ip的主机和通过dns解析主机两部分)

  3. 根据主机IP和端口创建socket套接字

  4. 执行一次连接,如果连接成功就返回这个套接字,否则返回错误码

源码

这部分源码相对比较简单,主要懂一些linux下网络编程就不难看懂了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

#include <sys/types.h>
#include <sys/socket.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <sys/time.h>
#include <string.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdarg.h>

// host是目标主机名,clientPort为端口
// 建立与目标的TCP连接,返回客户端连接使用的套接字
int Socket(const char *host, int clientPort)
{

int sock; // 本地套接字标识符
unsigned long inaddr; // 主机ip的数字形式(32位)
struct sockaddr_in ad; // 处理网络通信套接字地址结构,存储地址族、套接字端口号、ip地址
struct hostent *hp; // 存储了主机名、主机别名、ip及其类型、长度等,是gethostbyname返回值

memset(&ad, 0, sizeof(ad)); // 初始化套接字
ad.sin_family = AF_INET; // TCP/IP协议

inaddr = inet_addr(host); // 先尝试将host转换成整型(不经过dns解析的ip形式主机)
if (inaddr != INADDR_NONE)
memcpy(&ad.sin_addr, &inaddr, sizeof(inaddr)); // 成功则保存转换结果
else
{
// 否则需要先经过dns解析
hp = gethostbyname(host); // hp是存储解析结果的结构体
if (hp == NULL) // 解析失败
return -1;
memcpy(&ad.sin_addr, hp->h_addr, hp->h_length); // 解析成功同样需要保存整型结构的IP地址
}
ad.sin_port = htons(clientPort); // htons是将整型变量从主机字节顺序转变成网络字节顺序

sock = socket(AF_INET, SOCK_STREAM, 0); // 创建套接字
if (sock < 0)
return sock; // 创建失败
if (connect(sock, (struct sockaddr *)&ad, sizeof(ad)) < 0)
return -1; // 创建成功但是不能连接成功也认为是失败
return sock;
}

webbench.c源码剖析

文件说明

这个webbench.c文件是这个项目中最重要的文件,所有的处理操作都在这个文件中执行。

在这个文件头处可以看到可能令人疑惑的地方,在一行有一句#include "socket.c",也就是把另一个.c文件给include进来了,但一般我们include都是.h文件。

其实在c语言中#开头的指令是预处理指令,我们知道,c语言进行编译的第一步就是预处理,而include指令就是把include后紧跟的文件原封不动的插入进来,而不做改变。而平时我们不这么做的原因就是当多个文件都include一个.c文件时,这个.c文件内的函数会被重定义。但这个项目就两个文件,所以不用考虑那么多。

关于预处理

在linux环境下执行gcc -E webbench.c > result.txt就能看到预处理结果,当然为了便于观察,可以把除了#include "socket.c"的所有其他头文件都注释掉。

源码

由于这个文件比较大,所以分函数来进行展示

先来看头文件部分和全局变量部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66

#include "socket.c"
// 这里include了一个.c文件可能会令人费解,但是理解C语言的编译过程就容易看懂了
// #include是一个预处理指令,在预处理过程中会用"socket.c"的内容来替换这个预处理
// 所以相当于完成了两个.c文件的拼接,等效于将"socket.c"直接写在这个文件前面
// 平时之所以不这样用是因为可能出现重定义的现象
#include <unistd.h>
#include <sys/param.h>
#include <rpc/types.h>
#include <getopt.h>
#include <strings.h>
#include <time.h>
#include <signal.h>

volatile int timerexpired = 0; // 计时器是否到期,到期为1,未到期为0
int speed = 0; // 记录速度
int failed = 0; // 记录失败次数
int bytes = 0; //记录传输的数据量

int http10 = 1; // 0 - http/0.9, 1 - http/1.0, 2 - http/1.1

// 定义了http的方法和全局配置
#define METHOD_GET 0
#define METHOD_HEAD 1
#define METHOD_OPTIONS 2
#define METHOD_TRACE 3
#define PROGRAM_VERSION "1.5"
int method = METHOD_GET; // 默认请求方法
int clients = 1; // 客户端连接数目
int force = 0; // 是否不等待服务器响应,发送请求后直接关闭连接
int force_reload = 0; // 是否强制代理服务器重新发送请求
int proxyport = 80; // 代理端口
char *proxyhost = NULL; // 代理地址
int benchtime = 30; // 持续时间

// 网络相关的变量
int mypipe[2]; // 读写管道,0为读取端,1为写入端
char host[MAXHOSTNAMELEN]; // 保存主机的字符串
#define REQUEST_SIZE 2048 // 请求的最大长度
char request[REQUEST_SIZE]; // 请求内容

// 静态数组,用于记录各个选项的需要求参数
// no_argument表示选项没有参数,required_argument表示选项需要参数
static const struct option long_options[] =
{
{"force", no_argument, &force, 1},
{"reload", no_argument, &force_reload, 1},
{"time", required_argument, NULL, 't'},
{"help", no_argument, NULL, '?'},
{"http09", no_argument, NULL, '9'},
{"http10", no_argument, NULL, '1'},
{"http11", no_argument, NULL, '2'},
{"get", no_argument, &method, METHOD_GET},
{"head", no_argument, &method, METHOD_HEAD},
{"options", no_argument, &method, METHOD_OPTIONS},
{"trace", no_argument, &method, METHOD_TRACE},
{"version", no_argument, NULL, 'V'},
{"proxy", required_argument, NULL, 'p'},
{"clients", required_argument, NULL, 'c'},
{NULL, 0, NULL, 0}};

/* prototypes */
static void benchcore(const char *host, const int port, const char *request);
static int bench(void); // 执行压力测试的入口函数
static void build_request(const char *url); // 构造请求

前面这里就是定义了一些用到的宏和全局变量。

接下来我们来分析三个辅助函数,一个是用于设置定时器过期,一个用于显示帮助信息,另一个用于构造请求头。

其中,构造请求头的函数看起来稍微有些复杂,但是其实就是一些逻辑处理,然后根据http协议的定义手动构造出了请求体并保存在全局变量里面,这并不是我们分析的重点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158

// 静态函数,用于信号处理,用于设置定时器过期
static void alarm_handler(int signal)
{
timerexpired = 1;
}

// 返回帮助信息的函数
static void usage(void)
{
// printf == fprintf(stdout, ...)
// 不过fprintf的功能更强大,支持重定向
fprintf(stderr,
"webbench [option]... URL\n"
" -f|--force Don't wait for reply from server.\n"
" -r|--reload Send reload request - Pragma: no-cache.\n"
" -t|--time <sec> Run benchmark for <sec> seconds. Default 30.\n"
" -p|--proxy <server:port> Use proxy server for request.\n"
" -c|--clients <n> Run <n> HTTP clients at once. Default one.\n"
" -9|--http09 Use HTTP/0.9 style requests.\n"
" -1|--http10 Use HTTP/1.0 protocol.\n"
" -2|--http11 Use HTTP/1.1 protocol.\n"
" --get Use GET request method.\n"
" --head Use HEAD request method.\n"
" --options Use OPTIONS request method.\n"
" --trace Use TRACE request method.\n"
" -?|-h|--help This information.\n"
" -V|--version Display program version.\n");
}

// 构造请求头的函数
void build_request(const char *url)
{
/*
build_request的执行过程
就是根据请求方式、http协议版本等等信息来构造处http的报文
且这个报文是全局变量
*/

char tmp[10];
int i;

// 先把保存主机的请求体部分的地址区域清空
memset(host, 0, MAXHOSTNAMELEN);
memset(request, 0, REQUEST_SIZE);

// 然后根据选项来判断请求的http协议版本
if (force_reload && proxyhost != NULL && http10 < 1)
http10 = 1;
if (method == METHOD_HEAD && http10 < 1)
http10 = 1;
if (method == METHOD_OPTIONS && http10 < 2)
http10 = 2;
if (method == METHOD_TRACE && http10 < 2)
http10 = 2;

// 然后开始正式构造请求体
// 不过u1s1,这部分自己手动构造真是痛苦
switch (method)
{
default:
case METHOD_GET: // get请求
strcpy(request, "GET");
break;
case METHOD_HEAD: // head请求
strcpy(request, "HEAD");
break;
case METHOD_OPTIONS: // options请求
strcpy(request, "OPTIONS");
break;
case METHOD_TRACE: // trace请求
strcpy(request, "TRACE");
break;
}

strcat(request, " ");

// 处理url地址,做一些错误检测,而且不支持https
if (NULL == strstr(url, "://"))
{
fprintf(stderr, "\n%s: is not a valid URL.\n", url);
exit(2);
}
if (strlen(url) > 1500)
{
fprintf(stderr, "URL is too long.\n");
exit(2);
}
if (0 != strncasecmp("http://", url, 7))
{
fprintf(stderr, "\nOnly HTTP protocol is directly supported, set --proxy for others.\n");
exit(2);
}

// 计算出url去掉协议头的部分
i = strstr(url, "://") - url + 3;

// 要求结尾以/来结尾,不知道为啥
if (strchr(url + i, '/') == NULL)
{
fprintf(stderr, "\nInvalid URL syntax - hostname don't ends with '/'.\n");
exit(2);
}

// 处理使用代理的情况
if (proxyhost == NULL)
{
// 这种硬编码的东西看起来有点痛苦,就不考虑细节了
if (index(url + i, ':') != NULL && index(url + i, ':') < index(url + i, '/'))
{
strncpy(host, url + i, strchr(url + i, ':') - url - i);
memset(tmp, 0, 10);
strncpy(tmp, index(url + i, ':') + 1, strchr(url + i, '/') - index(url + i, ':') - 1);
proxyport = atoi(tmp);
if (proxyport == 0)
proxyport = 80;
}
else
{
strncpy(host, url + i, strcspn(url + i, "/"));
}
strcat(request + strlen(request), url + i + strcspn(url + i, "/"));
}
else
{
strcat(request, url);
}

if (http10 == 1)
strcat(request, " HTTP/1.0");
else if (http10 == 2)
strcat(request, " HTTP/1.1");

strcat(request, "\r\n");

if (http10 > 0)
strcat(request, "User-Agent: WebBench " PROGRAM_VERSION "\r\n");
if (proxyhost == NULL && http10 > 0)
{
strcat(request, "Host: ");
strcat(request, host);
strcat(request, "\r\n");
}

if (force_reload && proxyhost != NULL)
{
strcat(request, "Pragma: no-cache\r\n");
}

if (http10 > 1)
strcat(request, "Connection: close\r\n");

if (http10 > 0)
strcat(request, "\r\n");

printf("\nRequest:\n%s\n", request);
}

然后我们来进入main函数,main函数主要是解析选项,并构造url的请求头,然后调用bench函数来正式开始压力测试,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138

int main(int argc, char *argv[])
{
/*
main函数的处理过程就是先解析出所有的选项
然后构造指向目标地址的url请求
最后调用bench函数来执行压力测试
*/

int opt = 0; // 用于记录当前标志(其实是char)
int options_index = 0; // 用于标记当前选项处理到的索引未知
char *tmp = NULL;

if (argc == 1)
{
// 若如果不带任何参数执行此程序就直接退出
usage();
return 2;
}

// 否则循环解析命令
// getopt_long是解析命令的函数,此处不展开细🔒
// 就是通过这个步骤来解析所有的选项
while ((opt = getopt_long(argc, argv, "912Vfrt:p:c:?h", long_options, &options_index)) != EOF)
{
switch (opt)
{
case 0:
break;
case 'f':
force = 1;
break;
case 'r':
force_reload = 1;
break;
case '9':
http10 = 0;
break;
case '1':
http10 = 1;
break;
case '2':
http10 = 2;
break;
case 'V':
printf(PROGRAM_VERSION "\n");
exit(0);
case 't':
benchtime = atoi(optarg);
break;
case 'p':
/* proxy server parsing server:port */
// 由于代理是server:port的格式
// 所以先查找处:的位置
// strrchr就是查找并返回指向:字符的指针
tmp = strrchr(optarg, ':');
proxyhost = optarg;
if (tmp == NULL)
{
break;
}
if (tmp == optarg)
{
// 没有主机,即字符串是:....的形式
fprintf(stderr, "Error in option --proxy %s: Missing hostname.\n", optarg);
return 2;
}
if (tmp == optarg + strlen(optarg) - 1)
{
// 没有端口,即字符串是...:的形式
fprintf(stderr, "Error in option --proxy %s Port number is missing.\n", optarg);
return 2;
}
// 由于tmp指向了冒号,所以把冒号置'\0'就完成了分割操作
*tmp = '\0';
// 然后获取端口号
proxyport = atoi(tmp + 1);
break;
case ':':
case 'h':
case '?':
usage();
return 2;
break;
case 'c':
// atoi是把字符串转换成整型
clients = atoi(optarg);
break;
}
}

if (optind == argc)
{
// 最后一个被opt处理的选项与全部选项等长,就说明没有网址
fprintf(stderr, "webbench: Missing URL!\n");
usage();
return 2;
}

// 修改默认的客户端数目与时间
if (clients == 0)
clients = 1;
if (benchtime == 0)
benchtime = 30;

/* Copyright */
fprintf(stderr, "Webbench - Simple Web Benchmark " PROGRAM_VERSION "\n"
"Copyright (c) Radim Kolar 1997-2004, GPL Open Source Software.\n");

// 构造请求,存储在全局变量char request[REQUEST_SIZE]
// optind指向下一个未处理的字符串的索引
// 因此./webbench -c 5 -t 1 http://www.baidu.com/ wrong会被正常处理
// 而./webbench -c 5 -t 1 wrong http://www.baidu.com/ 不会被正常处理
build_request(argv[optind]);

printf("Runing info: ");

// 打印连接日志,为毛要写成这个样子??
if (clients == 1)
printf("1 client");
else
printf("%d clients", clients);

printf(", running %d sec", benchtime);

if (force)
printf(", early socket close");
if (proxyhost != NULL)
printf(", via proxy server %s:%d", proxyhost, proxyport);
if (force_reload)
printf(", forcing reload");

printf(".\n");

//调用bench函数,开始压力测试,bench() 为压力测试核心代码
return bench();
}

然后我们分析用于作为测试入口的bench函数。

这个函数内是使用fork创建出子进程,同时分配管道用于主进程与子进程之间通信,最后还设定了主进程的逻辑为从管道中不断读取子进程的数据并汇总。

这里我们可以学到一点:所有需要处理的东西全部由子进程去做,主进程只负责汇总或打印报告之类的,这样处理的时候子进程崩了也不影响主进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124

// 正式开始压力测试的入口函数,主要用于创建子进程、管道,以及主进程从管道中读取数据
static int bench(void)
{
int i, j, k;
pid_t pid = 0;
FILE *f;

// 构造一个socket套接字
i = Socket(proxyhost == NULL ? host : proxyhost, proxyport);
if (i < 0)
{
fprintf(stderr, "\nConnect to server failed. Aborting benchmark.\n");
return 1;
}

// 关闭连接,这次连接不计入测试
close(i);

// 创建管道,mypipe也是全局变量
// 管道用于两个进程之间数据交换
if (pipe(mypipe))
{
perror("pipe failed.");
return 3;
}

// fork出子进程
for (i = 0; i < clients; i++)
{
pid = fork();

if (pid <= (pid_t)0)
{
// 如果是子进程或者创建失败,休眠1s后退出循环,
// 让父进程先执行,完成初始化(因为创建子进程是很快的)
// 并且保证子进程中不会再fork出新的子进程
// 子进程不fork出新的子进程,所以总的子进程数目就很好控制了
sleep(1);
break;
}
}

//主进程创建进程失败就退出
if (pid < (pid_t)0)
{
fprintf(stderr, "problems forking worker no. %d\n", i);
perror("fork failed.");
return 3;
}

if (pid == (pid_t)0)
{
// 子进程调用benchcore去尽可能发送请求
if (proxyhost == NULL)
benchcore(host, proxyport, request);
else
benchcore(proxyhost, proxyport, request);

// 同时写入管道
f = fdopen(mypipe[1], "w");
if (f == NULL)
{
perror("open pipe for writing failed.");
return 3;
}

// 写入数据,来源于全局变量
fprintf(f, "%d %d %d\n", speed, failed, bytes);
fclose(f);

return 0;
}
else
{
// 主进程读取管道
f = fdopen(mypipe[0], "r");
if (f == NULL)
{
perror("open pipe for reading failed.");
return 3;
}

// 定义流 stream 应如何缓冲
// 设置不使用缓冲。每个I/O操作都被即时写入管道
setvbuf(f, NULL, _IONBF, 0);

// 初始化主进程的变量
speed = 0;
failed = 0;
bytes = 0;

while (1)
{
// 获取成功读取的参数个数
pid = fscanf(f, "%d %d %d", &i, &j, &k);
if (pid < 2)
{
fprintf(stderr, "Some of our childrens died.\n");
break;
}

speed += i;
failed += j;
bytes += k;

// 客户端数减一后如果等于0,说明没有多的客户端数据读取,直接退出循环
if (--clients == 0)
break;
}

fclose(f);

// 输出结果
printf("\nSpeed=%d pages/min, %d bytes/sec.\nRequests: %d susceed, %d failed.\n",
(int)((speed + failed) / (benchtime / 60.0f)),
(int)(bytes / (float)benchtime),
speed,
failed);
}

return i;
}

最后是子进程发起请求的函数benchcore函数,这个函数其实逻辑也很简单,就是构造请求并发起,然后看看有没有错误,不过错误种类还挺多。

另外,这个函数还使用了信号量来作为定时器,这部分可以参考,即:在不影响程序执行的前提下进行定时

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90

// 子进程发送请求的函数
void benchcore(const char *host, const int port, const char *req)
{
int rlen;
char buf[1500];
int s, i;
struct sigaction sa;

// 当程序执行到指定的秒数之后,发送SIGALRM信号,
// 即设置alam_handler函数为信号处理函数
sa.sa_handler = alarm_handler;
sa.sa_flags = 0;
if (sigaction(SIGALRM, &sa, NULL))
exit(3);

alarm(benchtime); // after benchtime,then exit

rlen = strlen(req);
nexttry:
while (1)
{
if (timerexpired)
{
// 定时器过期
// 减去由于定时器过期引起的失败
if (failed > 0)
{
failed--;
}
return;
}

s = Socket(host, port);
// 构造并发起请求失败
if (s < 0)
{
failed++;
continue;
}
// 如果请求报文写入套接字失败
if (rlen != write(s, req, rlen))
{
failed++;
close(s);
continue;
}
// 如果使用HTTP/0.9协议,因为会在服务器回复后自动断开连接,所以可以先关闭写端
// 如果写端已经被关闭了,则说明异常连接
if (http10 == 0)
if (shutdown(s, 1))
{
failed++;
close(s);
continue;
}
if (force == 0)
{
// 读取服务器的数据
while (1)
{
if (timerexpired)
break;
// 将数据读取进buf中
i = read(s, buf, 1500);
if (i < 0)
{
failed++;
close(s);
goto nexttry;
}
// 文件尾
else if (i == 0)
break;
// 如果读取到了数据,将总共传送的字节数加上读取到的数据的字节数
else
bytes += i;
}
}
// 关闭连接,如果失败,测试失败数加一,继续循环
if (close(s))
{
failed++;
continue;
}
speed++;
}
}


总结

流程解析

编程小技巧

  • getopt_long() : 其函数族成员有getopt、getopt_long、getopt_long_only,可以用来解析命令行参数,很实用。位于getopt.h,可以参阅这篇文章来使用。

  • pipe() : 管道是两个进程之间的连接,一个进程的标准输出成为另一个进程的标准输入。在UNIX操作系统中,管道用于进程间通信。可以参阅这篇文章来使用。

  • fork() : 用于创建出子进程,调用一次返回两次,子进程拥有父进程所有的打开的文件与变量。另外,在循环中使用fork会导致子进程继续fork,非常麻烦,为了计数方便可以让子进程在循环中break掉。

  • fprintf() : printf() == fprintf(stdout,...)这个函数功能更强大,支持重定向。

  • alarm() : alarm也称为闹钟函数,它可以在进程中设置一个定时器,当定时器指定的时间到时,它向进程发送SIGALRM信号,可以设置对应的信号处理函数去处理这个信号。