Stone's Blog


  • 首页

  • 归档

  • 标签
Stone's Blog

centos安装gcc6

发表于 2017-07-25 |

公司使用的系统是 centos6.9,上面的 gcc 版本是 4.4.7,不支持 c++11 标准,因此打算安装一下 gcc6.1,它直接支持 c++14。

安装步骤

通过 Devtoolset 可以非常容易安装想要的 gcc 版本,步骤如下:

1
2
3
4
5
6
7
8
#安装scl
$ sudo yum install centos-release-scl
#安装想要的gcc版本,如gcc6
$ sudo yum install devtoolset-6
#安装g++
sudo yum install devtoolset-6-gcc-c++

上面会将新版的 gcc 与 g++ 安装在 /opt/rh/devtoolset-6/root/bin 目录下,那么可用如下方式使用它们:

1
/opt/rh/devtoolset-6/root/bin/g++ hello.cpp

另外 scl 提供了一个更简单的方式来启用新版本的 gcc:

1
scl enable devtoolset-6 bash

上面的命令会将新版的 gcc 路径加入环境变量,然后新启动一个 bash,那么在新的 bash 中就可以像以前一样直接用 gcc 和 g++ 进行编译了。

Stone's Blog

nginx互斥锁的实现

发表于 2017-06-22 |

nginx 基于原子操作、信号量以及文件锁实现了一个简单高效的互斥锁,当多个 worker 进程之间需要互斥操作时都会用到。下面来看下 nginx 是如何实现它的。

原子操作

在实现互斥锁时用到了原子操作,先来了解一下 nginx 下提供的两个原子操作相关的函数:

1
2
3
4
5
static ngx_inline ngx_atomic_uint_t
ngx_atomic_cmp_set(ngx_atomic_t *lock, ngx_atomic_uint_t old, ngx_atomic_uint_t set)
static ngx_inline ngx_atomic_int_t
ngx_atomic_fetch_add(ngx_atomic_t *value, ngx_atomic_int_t add)

第一个函数是一个 CAS 操作,首先它比较 lock 地址处的变量是否等于 old, 如果相等,就把 lock 地址处的变量设为 set 变返回成功,否则返回失败。注意上述过程是作为一个原子一起进行的,不会被打断。 用代码可以描述如下:

1
2
3
4
5
6
7
8
9
10
static ngx_inline ngx_atomic_uint_t
ngx_atomic_cmp_set(ngx_atomic_t *lock, ngx_atomic_uint_t old, ngx_atomic_uint_t set)
{
if (*lock == old) {
*lock = set;
return 1;
}
return 0;
}

第二个函数是读取 value 地址处的变量,并将其与 add 相加的结果再写入 *lock,然后返回原来 *lock 的值,这些操作也是作为一个整体完成的,不会被打断。用代码可描述如下:

1
2
3
4
5
6
7
8
9
10
static ngx_inline ngx_atomic_int_t
ngx_atomic_fetch_add(ngx_atomic_t *value, ngx_atomic_int_t add)
{
ngx_atomic_int_t old;
old = *value;
*value += add;
return old;
}

nginx 在实现这两个函数时会首先判断有没有支持原子操作的库,如果有,则直接使用库提供的原子操作实现,如果没有,则会使用汇编语言自己实现。下面以 x86 平台下实现 ngx_atomic_cmp_set 的汇编实现方式,实现主要使用了 cmpxchgq 指令,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static ngx_inline ngx_atomic_uint_t
ngx_atomic_cmp_set(ngx_atomic_t *lock, ngx_atomic_uint_t old,
ngx_atomic_uint_t set)
{
u_char res;
__asm__ volatile ( //volatile 关键字告诉编译器不要对下面的指令循序进行调整与优化。
NGX_SMP_LOCK //这是一个宏,如果是当前是单cpu,则展开为空,如果是多cpu,则展开为lock;,它会锁住内存地址进行排他访问
" cmpxchgl %3, %1; " //进行 cas 操作
" sete %0; " //将操作结果写到 res 变量
: "=a" (res) // 输出部分
: "m" (*lock), "a" (old), "r" (set) // 输入部分
: "cc", "memory" // 破坏描述部分,表示修改了哪些寄存器和内存,提醒编译器优化时要注意。
);
return res;
}

上面的代码采用 gcc 嵌入汇编方式来进行编写,了解了 cmpxchgq 指令后还是比较容易理解的。

锁结构体

首先 nginx 使用 ngx_shmtx_lock 结构体表示锁,它的各个成员变量如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct {
#if (NGX_HAVE_ATOMIC_OPS)
ngx_atomic_t *lock;
#if (NGX_HAVE_POSIX_SEM)
ngx_atomic_t *wait;
ngx_uint_t semaphore;
sem_t sem;
#endif
#else // 不支持原子变量,使用文件锁,效率稍低。
ngx_fd_t fd;
u_char *name;
#endif
ngx_uint_t spin; //获取锁时尝试的自旋次数,使用原子操作实现锁时才有意义
} ngx_shmtx_t;

上面的结构体定义使用了两个宏:NGX_HAVE_ATOMIC_OPS 与 NGX_HAVE_POSIX_SEM,分别用来代表操作系统是否支持原子变量操作与信号量。根据这两个宏的取值,可以有3种不同的互斥锁实现:

  1. 不支持原子操作。
  2. 支持原子操作,但不支持信号量
  3. 支持原子操作,也支持信号量

第1种情况最简单,会直接使用文件锁来实现互斥锁,这时该结构体只有 fd 、 name和 spin 三个字段,但 spin 字段是不起作用的。对于2和3两种情况 nginx 均会使用原子变量操作来实现一个自旋锁,其中 spin 表示自旋次数。它们两个的区别是:在支持信号量的情况下,如果自旋次数达到了上限而进程还未获取到锁,则进程会在信号量上阻塞等待,进入睡眠状态。不支持信号量的情况,则不会有这样的操作,而是通过调度器直接 「让出」cpu。 下面对这三种情况下锁的实现分别进行介绍。

基于文件锁实现的锁

锁的创建

首先通过下面的函数创建一个锁:

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
/*
mtx:要创建的锁
addr:使用文件锁实现互斥锁时不会用到该变量
name:文件锁使用的文件
*/
ngx_int_t
ngx_shmtx_create(ngx_shmtx_t *mtx, ngx_shmtx_sh_t *addr, u_char *name)
{
if (mtx->name) { // mtx->name不为NULL,说明它之前已经创建过锁
if (ngx_strcmp(name, mtx->name) == 0) { // 之前创建过锁,且与这次创建锁的文件相同,则不需要创建,直接返回
mtx->name = name;
return NGX_OK;
}
// 销毁之前创建到锁,其实就是关闭之前创建锁时打开的文件。
ngx_shmtx_destroy(mtx);
}
//打开文件
mtx->fd = ngx_open_file(name, NGX_FILE_RDWR, NGX_FILE_CREATE_OR_OPEN,
NGX_FILE_DEFAULT_ACCESS);
//打开文件失败,打印日志,然后返回
if (mtx->fd == NGX_INVALID_FILE) {
ngx_log_error(NGX_LOG_EMERG, ngx_cycle->log, ngx_errno,
ngx_open_file_n " \"%s\" failed", name);
return NGX_ERROR;
}
//使用锁时只需要该文件在内核中的inode信息,所以将该文件删掉
if (ngx_delete_file(name) == NGX_FILE_ERROR) {
ngx_log_error(NGX_LOG_ALERT, ngx_cycle->log, ngx_errno,
ngx_delete_file_n " \"%s\" failed", name);
}
mtx->name = name;
return NGX_OK;
}

阻塞锁的获取

当进程需要进行阻塞加锁时,通过下面的函数进行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*
通过文件锁的方式实现互斥锁,如果该文件锁正被其他进程占有,则会导致进程阻塞。
*/
void
ngx_shmtx_lock(ngx_shmtx_t *mtx)
{
ngx_err_t err;
//通过获取文件锁来进行加锁。
err = ngx_lock_fd(mtx->fd);
if (err == 0) {
return;
}
ngx_log_abort(err, ngx_lock_fd_n " %s failed", mtx->name);
}

上面函数主要的操作就是通过 ngx_lock_fd 来获取锁,它的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ngx_err_t
ngx_lock_fd(ngx_fd_t fd)
{
struct flock fl;
ngx_memzero(&fl, sizeof(struct flock));
//设置文件锁的类型为写锁,即互斥锁
fl.l_type = F_WRLCK;
fl.l_whence = SEEK_SET;
//设置操作为F_SETLKW,表示获取不到文件锁时,会阻塞直到可以获取
if (fcntl(fd, F_SETLKW, &fl) == -1) {
return ngx_errno;
}
return 0;
}

它主要是通过 fcntl 函数来获取文件锁。

非阻塞锁的获取

上面获取锁的方式是阻塞式的,在获取不到锁时进程会阻塞,但有时候我们并不希望这样,而是不能获取锁时直接返回,nginx 通过这么函数来非阻塞的获取锁:

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
ngx_uint_t
ngx_shmtx_trylock(ngx_shmtx_t *mtx)
{
ngx_err_t err;
// 与上面的阻塞版本比较,最主要的变化是将 ngx_lock_fd 函数换成了 ngx_trylock_fd
err = ngx_trylock_fd(mtx->fd);
// 获取锁成功,返回1
if (err == 0) {
return 1;
}
// 获取锁失败,如果错误码是 NGX_EAGAIN,表示文件锁正被其他进程占用,返回0
if (err == NGX_EAGAIN) {
return 0;
}
#if __osf__ /* Tru64 UNIX */
if (err == NGX_EACCES) {
return 0;
}
#endif
// 其他错误都不应该发生,打印错误日志
ngx_log_abort(err, ngx_trylock_fd_n " %s failed", mtx->name);
return 0;
}

可以看到与阻塞版本相比,非阻塞版本最主要的变化 ngx_lock_fd 换成了 ngx_trylock_fd, 它的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ngx_err_t
ngx_trylock_fd(ngx_fd_t fd)
{
struct flock fl;
ngx_memzero(&fl, sizeof(struct flock));
//锁的类型同样是写锁
fl.l_type = F_WRLCK;
fl.l_whence = SEEK_SET;
//操作变成了 F_SETLK, 该操作在获取不到锁时会直接返回,而不会阻塞进程。
if (fcntl(fd, F_SETLK, &fl) == -1) {
return ngx_errno;
}
return 0;
}

锁的释放

上面说了如何加锁,接下来看一下如何释放锁,逻辑比较简单,直接放代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void
ngx_shmtx_unlock(ngx_shmtx_t *mtx)
{
ngx_err_t err;
//调用 ngx_unlock_fd函数释放锁
err = ngx_unlock_fd(mtx->fd);
if (err == 0) {
return;
}
ngx_log_abort(err, ngx_unlock_fd_n " %s failed", mtx->name);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ngx_err_t
ngx_unlock_fd(ngx_fd_t fd)
{
struct flock fl;
ngx_memzero(&fl, sizeof(struct flock));
//锁的类型为 F_UNLCK, 表示释放锁
fl.l_type = F_UNLCK;
fl.l_whence = SEEK_SET;
if (fcntl(fd, F_SETLK, &fl) == -1) {
return ngx_errno;
}
return 0;
}

基于原子操作实现锁

上面谈到了在不支持原子操作时,nginx 如何使用文件锁来实现互斥锁。现在操作系统一般都支持原子操作,用它实现互斥锁效率会较文件锁的方式更高,这也是 nginx 默认选用该种方式实现锁的原因,下面看一下它是如何实现的。

锁的创建

与上面一样,我们还是先看是如何创建锁的:

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
/*
mtx: 要创建的锁
addr:创建锁时,内部用到的原子变量
name:没有意义,只有上
*/
ngx_int_t
ngx_shmtx_create(ngx_shmtx_t *mtx, ngx_shmtx_sh_t *addr, u_char *name)
{
// 保存原子变量的地址,由于锁时多个进程之间共享的,那么原子变量一般在共享内存进行分配
// 上面的addr就表示在共享内存中分配的内存地址,至于共享内存的分配下次再说
mtx->lock = &addr->lock;
// 在不支持信号量时,spin只表示锁的自旋次数,那么该值为0或负数表示不进行自旋,直接让出cpu,
// 当支持信号量时,它为-1表示,不要使用信号量将进程置于睡眠状态,这对 nginx 的性能至关重要。
if (mtx->spin == (ngx_uint_t) -1) {
return NGX_OK;
}
// 默认自旋次数是2048
mtx->spin = 2048;
// 支持信号量,继续执行下面代码,主要是信号量的初始化。
#if (NGX_HAVE_POSIX_SEM)
mtx->wait = &addr->wait;
//初始化信号量,第二个参数1表示,信号量使用在多进程环境中,第三个参数0表示信号量的初始值
//当信号量的值小于等于0时,尝试等待信号量会阻塞
//当信号量大于0时,尝试等待信号量会成功,并把信号量的值减一。
if (sem_init(&mtx->sem, 1, 0) == -1) {
ngx_log_error(NGX_LOG_ALERT, ngx_cycle->log, ngx_errno,
"sem_init() failed");
} else {
mtx->semaphore = 1;
}
#endif
return NGX_OK;
}

该函数的 addr 指针变量指向进行原子操作用到的原子变量,它的类型如下:

1
2
3
4
5
6
7
8
typedef struct {
// 通过对该变量进行原子操作来进行锁的获取与释放
ngx_atomic_t lock;
#if (NGX_HAVE_POSIX_SEM)
// 支持信号量时才会有该成语变量,表示当前在在变量上等待的进程数目。
ngx_atomic_t wait;
#endif
} ngx_shmtx_sh_t;

由于锁是多个进程之间共享的, 所以 addr 指向的内存都是在共享内存进行分配的。

阻塞锁的获取

与文件锁实现的互斥锁一样,依然有阻塞和非阻塞类型,下面首先来看下阻塞锁的实现,相比于文件锁实现的方式要复杂很多:

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
void
ngx_shmtx_lock(ngx_shmtx_t *mtx)
{
ngx_uint_t i, n;
ngx_log_debug0(NGX_LOG_DEBUG_CORE, ngx_cycle->log, 0, "shmtx lock");
for ( ;; ) {
//尝试获取锁,如果*mtx->lock为0,表示锁未被其他进程占有,
//这时调用ngx_atomic_cmp_set这个原子操作尝试将*mtx->lock设置为进程id,如果设置成功,则表示加锁成功,否则失败。
//注意:由于在多进程环境下执行,*mtx->lock == 0 为真时,并不能确保ngx_atomic_cmp_set函数执行成功
if (*mtx->lock == 0 && ngx_atomic_cmp_set(mtx->lock, 0, ngx_pid)) {
return;
}
// 获取锁失败了,这时候判断cpu的数目,如果数目大于1,则先自旋一段时间,然后再让出cpu
// 如果cpu数目为1,则没必要进行自旋了,应该直接让出cpu给其他进程执行。
if (ngx_ncpu > 1) {
for (n = 1; n < mtx->spin; n <<= 1) {
for (i = 0; i < n; i++) {
// ngx_cpu_pause函数并不是真的将程序暂停,而是为了提升循环等待时的性能,并且可以降低系统功耗。
// 实现它时往往是一个指令: `__asm__`("pause")
ngx_cpu_pause();
}
// 再次尝试获取锁
if (*mtx->lock == 0
&& ngx_atomic_cmp_set(mtx->lock, 0, ngx_pid))
{
return;
}
}
}
// 如果支持信号量,会执行到这里
#if (NGX_HAVE_POSIX_SEM)
// 上面自旋次数已经达到,依然没有获取锁,将进程在信号量上挂起,等待其他进程释放锁后再唤醒。
if (mtx->semaphore) { // 使用信号量进行阻塞,即上面设置创建锁时,mtx的spin成员变量的值不是-1
// 当前在该信号量上等待的进程数目加一
(void) ngx_atomic_fetch_add(mtx->wait, 1);
// 尝试获取一次锁,如果获取成功,将等待的进程数目减一,然后返回
if (*mtx->lock == 0 && ngx_atomic_cmp_set(mtx->lock, 0, ngx_pid)) {
(void) ngx_atomic_fetch_add(mtx->wait, -1);
return;
}
ngx_log_debug1(NGX_LOG_DEBUG_CORE, ngx_cycle->log, 0,
"shmtx wait %uA", *mtx->wait);
// 在信号量上进行等待
while (sem_wait(&mtx->sem) == -1) {
ngx_err_t err;
err = ngx_errno;
if (err != NGX_EINTR) {
ngx_log_error(NGX_LOG_ALERT, ngx_cycle->log, err,
"sem_wait() failed while waiting on shmtx");
break;
}
}
ngx_log_debug0(NGX_LOG_DEBUG_CORE, ngx_cycle->log, 0,
"shmtx awoke");
// 执行到此,肯定是其他进程释放锁了,所以继续回到循环的开始,尝试再次获取锁,注意它并不会执行下面的386行
continue;
}
#endif
// 在没有获取到锁,且不使用信号量时,会执行到这里,他一般通过 sched_yield 函数实现,让调度器暂时将进程切出,让其他进程执行。
// 在其它进程执行后有可能释放锁,那么下次调度到本进程时,则有可能获取成功。
ngx_sched_yield();
}
}

上面代码的实现流程通过注释已经描述的很清楚了,再强调一点就是,使用信号量与否的区别就在于获取不到锁时进行的操作不同,如果使用信号量,则会在信号量上阻塞,进程进入睡眠状态。而不使用信号量,则是暂时「让出」cpu,进程并不会进入睡眠状态,这会减少内核态与用户态度切换带来的开销,所以往往性能更好,因此在 nginx 中使用锁时一般不使用信号量,比如负载均衡均衡锁的初始化方式如下:

1
ngx_accept_mutex.spin = (ngx_uint_t) -1;

将spin值设为-1,表示不使用信号量。

非阻塞锁的获取

非阻塞锁的代码就比较简单了,因为是非阻塞的,所以在获取不到锁时不需要考虑进程是否需要睡眠,也就不需要使用信号量,实现如下:

1
2
3
4
5
ngx_uint_t
ngx_shmtx_trylock(ngx_shmtx_t *mtx)
{
return (*mtx->lock == 0 && ngx_atomic_cmp_set(mtx->lock, 0, ngx_pid));
}

锁的释放

释放锁时,主要操作是将原子变量设为0,如果使用信号量,则可能还需要唤醒在信号量上等候的进程:

1
2
3
4
5
6
7
8
9
10
11
12
void
ngx_shmtx_unlock(ngx_shmtx_t *mtx)
{
if (mtx->spin != (ngx_uint_t) -1) {
ngx_log_debug0(NGX_LOG_DEBUG_CORE, ngx_cycle->log, 0, "shmtx unlock");
}
//释放锁,将原子变量设为0,同时唤醒在信号量上等待的进程
if (ngx_atomic_cmp_set(mtx->lock, ngx_pid, 0)) {
ngx_shmtx_wakeup(mtx);
}
}

其中 ngx_shmtx_wakeup 的实现如下:

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
static void
ngx_shmtx_wakeup(ngx_shmtx_t *mtx)
{
// 如果不支持信号量,那么该函数为空,啥也不做
#if (NGX_HAVE_POSIX_SEM)
ngx_atomic_uint_t wait;
// 如果没有使用信号量,直接返回
if (!mtx->semaphore) {
return;
}
// 将在信号量上等待的进程数减1,因为是多进程环境,ngx_atomic_cmp_set不一定能一次成功,所以需要循环调用
for ( ;; ) {
wait = *mtx->wait;
// wait 小于等于0,说明当前没有进程在信号量上睡眠
if ((ngx_atomic_int_t) wait <= 0) {
return;
}
if (ngx_atomic_cmp_set(mtx->wait, wait, wait - 1)) {
break;
}
}
ngx_log_debug1(NGX_LOG_DEBUG_CORE, ngx_cycle->log, 0,
"shmtx wake %uA", wait);
// 将信号量的值加1
if (sem_post(&mtx->sem) == -1) {
ngx_log_error(NGX_LOG_ALERT, ngx_cycle->log, ngx_errno,
"sem_post() failed while wake shmtx");
}
#endif
}

锁的销毁

因为锁的销毁代码比较简单,就不分开进行说明了。对于基于文件锁实现的互斥锁在销毁时需要关闭打开的文件。对于基于原子变量实现的锁,如果支持信号量,则需要销毁创建的信号量,代码分别入下:

基于文件锁实现的锁的销毁:

1
2
3
4
5
6
7
8
void
ngx_shmtx_destroy(ngx_shmtx_t *mtx)
{
if (ngx_close_file(mtx->fd) == NGX_FILE_ERROR) {
ngx_log_error(NGX_LOG_ALERT, ngx_cycle->log, ngx_errno,
ngx_close_file_n " \"%s\" failed", mtx->name);
}
}

基于原子操作实现的锁的销毁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void
ngx_shmtx_destroy(ngx_shmtx_t *mtx)
{
#if (NGX_HAVE_POSIX_SEM)
if (mtx->semaphore) {
if (sem_destroy(&mtx->sem) == -1) {
ngx_log_error(NGX_LOG_ALERT, ngx_cycle->log, ngx_errno,
"sem_destroy() failed");
}
}
#endif
}
Stone's Blog

实现一个简单协程

发表于 2017-04-19 |

最近突然想实现一个简单的协程,一来是觉得比较有意思,二来是打算学习一下如何在 gcc 中嵌入汇编。

Stone's Blog

使用vagrant和nginx开发静态文件修改不生效问题

发表于 2017-01-06 |

现象

最近使用 Vagrant 开发一个网站,里面跑的是 Nginx 服务器,一切工作都很正常,除了 CSS、JS 等静态文件的修改不能实时生效之外。当修改一个静态文件后,查看响应头:

1
2
3
4
5
6
7
8
9
HTTP/1.1 200 OK
Server: nginx/1.10.2
Date: Fri, 06 Jan 2017 03:20:44 GMT
Content-Type: text/css
Content-Length: 3217
Last-Modified: Fri, 06 Jan 2017 03:20:42 GMT
Connection: keep-alive
ETag: "586f0d0a-c91"
Accept-Ranges: bytes

通过 Last-Modified 以及 ETag 字段可以看到 Nginx 确实探测到了修改,但是响应内容却依然是以前的,很是奇怪。

原因

后来通过 stackoverflow 找到了这个问题的原因,我的 Vagrant 底层用的是 VirtualBox, 而 vboxvfs 在使用 mmap 时会有一些问题,具体表现为在虚拟机之外修改文件后,虚拟机内以 mmap 方式打开文件的不能同步修改。而 Nginx 在 sendfile 选项开启后会通过 mmap 来加速文件的访问,自然就会有上面说的这个问题。

解决办法

知道问题原因后,解决办法就很自然了,只要将 nginx 的 sendfile 选项关闭即可。

1
sendfile off;
Stone's Blog

nginx的延迟关闭

发表于 2016-11-18 |

背景

最近业务方反馈线上 Nginx 经常会打出一些『奇怪』的 access 日志,奇怪之处在于这些日志的 request_time 值总是正好 upstream_response_time 的值大5秒,于是我就帮他们查看了一下导致这个问题的原因,本文记录一下最终调查的结论以及过程。

结论

首先给出产生该问题的原因,这样不愿意看细节的同学看完这段就可以结束阅读了。该问题是由 Nginx 的延迟关闭(lingering close)连接导致的。Nginx 为了能够平滑关闭连接,采用了延迟关闭,它的工作方式如下:Nginx 在给客户端发送完最后一个数据包后会首先关闭 TCP 连接的写端(TCP 是全双工协议,任何一端都即可读也可写),表示服务端不会再向客户端发送任何数据,但是不会立即关闭 TCP 连接的读端,而是等待一个超时,在超时到达后如果客户端还没有数据发来,Nginx 才会关闭TCP的读端,从而关闭整个连接,然后再输出日志。另一方面,Nginx 是在关闭连接后才输出日志,所以在输出日志之前响应早就发送给了用户,因此对业务几乎没有影响。但是这也会导致 requset_time 值变得不准确,使其失去统计意义,开启 Keep-Alive 可以部分解决这一问题。

问题追踪

首先我们先来了解一下 request_time 与 upstream_response_time 这两个值在 Nginx 中是怎么定义的,它们的含义在 Nginx 手册中描述如下:

  • request_time:从接受到请求数据的第一个字节开始到发送完响应的最后一个字节之间的时间
  • upstream_response_time:从连接上upstream开始到接受完 upstream 响应的最后一个字节之间的时间。

从上面的定义可以看到, request_time 的值包含了接收用户请求数据、处理请求以及给用户发送响应这三部分的耗时,而 upstream_response_time 只是 Nginx 和上游服务交互的时间,在我们这里就是PHP 处理请求的时间。那么由于网络原因,request_time 大于甚至远大于upstream_response_time 都是很正常的,但是总是大5秒就很奇怪了。

Nginx 配置导致的么?

因为两者总是相差5秒,很容易让人想到可能是Nginx的配置文件中的某个参数导致了该问题,通过查看配置文件确实发现了一个可疑的配置项目:

1
fastcgi_connect_timeout 5

这个配置表示将 Nginx 与 PHP-FPM 之间的连接超时设置为5秒,那么导致该问题的一个可能的原因就是当 Nginx 第一次尝试与 PHP-FPM 建立连接超时了,第二次尝试才连上,这样就会正好多出了一个5秒的连接超时时间。可是进一步查看日志发现,PHP 的请求处理日志早在 Nginx 日志之前5秒就打出来了,而且如果 Nginx 连接 PHP 超时是会输出 error 日志的,但是线上的 error 日志里面并没有连接超时的记录,所以这个原因很快被否决了。

Nagle 算法惹的祸?

既然配置文件中没有显式的配置会导致该问题,那么就有可能是 Nginx 的默认配置导致的,因此我搜索了一下源代码中与5有关的内容,希望能发现一些蛛丝马迹,结果发现了一段如下的注释:

1
2
3
4
5
6
7
8
Therefore we use the TCP_NOPUSH option (similar to Linux's TCP_CORK)
to postpone the sending - it not only sends a header and the first part of
the file in one packet, but also sends the file pages in the full packets.
But until FreeBSD 4.5 turning TCP_NOPUSH off does not flush a pending
data that less than MSS, so that data may be sent with 5 second delay.
So we do not use TCP_NOPUSH on FreeBSD prior to 4.5, although it can be used
for non-keepalive HTTP connections.

上面注释的大概意思是,在较老的 FreeBSD 的操作系统上,就算关闭了 TCP_NOPUSH 参数,如果一个包小于 MSS,依然有可能会被延迟5秒发送。TCP_NOPUSH 参数是用来控制 TCP 的 Nagle 算法的,该算法的具体内容可以查阅网上资料,其核心思想是将多个连续的小包累积成一个大包,然后一次性发送,这可以提升网络的利用率。Nginx 中还有一个配置项也与 Nagle 算法相关,那就是 TCP_NODELAY,它的含义与 TCP_NOPUSH 正好相反,表示关闭 TCP 的 Nagle 化,也就是内核收到数据后不管大小直接发送。这两个配置看似互斥,但是在实际应用中,我们却将它们都打开,因为 Nginx 可以通过配合使用这两个配置来最大效率的利用网络。配合方式如下:首先根据 TCP_NOPUSH 开启 Nagle 算法,将数据累积到缓冲区中,当需要发送的数据都累积完成但是还没有达到 MSS 时,立即根据TCP_NODELAY 关闭 Nagel 算法,此时内核会一次性将缓冲区中的数据发出。总结为一句话就是:累积足够量的数据(NOPUSH)然后立即发出(NODELAY)。
我们线上的Linux内核版本是2.6.32,比较老了,所以我们猜想会不会也存在上面所说的这个问题,这时组内其他同学查看 Nginx 配置文件,发现 sendfile,TCP_NOPUSH 以及 TCP_NODELAY 这三个开关都打开了,但是 Keep-Alive 却没有打开,而 Nginx 手册中明确写到只有在开启 sendfile 的情况下 TCP_NOPUSH 才会生效,以及开启 Keep-Alive 的前提下 TCP_NODELAY 才会打开。换句话说,我们线上只开启了 TCP_NOPUSH,却没有开启 TCP_NODELAY,这就有可能导致包的延迟发送。因此我们联系了运维相关的同学,将 Keep-Alive 打开,也就是让 TCP_NODELAY 生效,然后观察日志,发现相差5秒的异常日志真的消失了。这时我们都以为问题的原因找到了。

真的是Nagle算法惹的祸么?

虽然开启 Keep-Alive 使 TCP_NODELAY 生效后,异常日志消失了,但是我心里依然有几个疑问,总觉得这不是导致问题的根本原因:

  1. Nagle 算法是内核层面的,并不是Nginx实施的,也就是说累积包的过程是在内核中完成的,Nginx 只要把包写入到内核缓冲区后就会认为发送数据成功,然后直接记录日志,而不用等待这个累积的过程。
  2. Nagle 算法中累积超时一般设置的是200毫秒,就是说如果200毫秒还没能凑到一个 MSS,也会直接将缓冲区的内容发送出去,与5秒相距甚远。
  3. 如果是 TCP_NODELAY 关闭导致的原因,那么在开启 Keep-Alive 然后显式将 TCP_NODELAY 关闭的情况下,也应该会打出奇怪日志,可是我在线下并没能复现这一假设。

真正的原因:socket lingering close

在几个猜想都不对后,觉得还是应该调试一下 Nginx 代码才能发现问题。因为担心直接 gdb 调试可能会导致 Nginx 的性能下降,以至于不能触发可以打出奇怪日志的条件,因此我想到了一个简单的变通方法:只要能获取计算 request_time 之前的所有函数调用栈,那么也就能够大致知道时间花在哪了。根据这个思路我修改了一下 Nginx 源代码,在获取时间的地方有意加了一个对内存的非法访问:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ngx_http_log_request_time(ngx_http_request_t *r, u_char *buf,
ngx_http_log_op_t *op)
{
ngx_time_t *tp;
ngx_msec_int_t ms;
tp = ngx_timeofday();
ms = (ngx_msec_int_t)
((tp->sec - r->start_sec) * 1000 + (tp->msec - r->start_msec));
ms = ngx_max(ms, 0);
//如果响应时间是5s,就触发下面的内存访问错误,从而产生一个core。
if (ms == 5000) {
*(char *)(0) = 'N';
}
return ngx_sprintf(buf, "%T.%03M", (time_t) ms / 1000, ms % 1000);
}

采用修改后的代码,成功获取了一个 core ,根据 core 得到的调用栈如下:

调用栈

根据调用栈可以看到,在打日志之前,依次调用了三个红色框中的函数,它们都是用来处理连接关闭的。也就是说,在短连接的情况下,Nginx 只有在关闭与客户端的连接后才会开始输出日志,而不是给客户端发送完数据后就打日志。那么这个关闭连接的过程的耗时就很有可能是request_time 比 upstream_response_time 多出来的时间。我们接下来再来具体通过源代码看一下 Nginx 关闭连接的过程,主要的代码如下:

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
ngx_http_finalize_connection(ngx_http_request_t *r)
{
if (r->reading_body) {
r->keepalive = 0;
r->lingering_close = 1;
}
//如果开启了长连接且长连接未超时,那么走长连接处理相关的代码
if (!ngx_terminate
&& !ngx_exiting
&& r->keepalive
&& clcf->keepalive_timeout > 0)
{
ngx_http_set_keepalive(r);
return;
}
//不再需要keepalive,即连接需要关闭,并且打开了lingering close,就通过lingering close的方式来关闭连接,也就是延迟关闭
if (clcf->lingering_close == NGX_HTTP_LINGERING_ALWAYS
|| (clcf->lingering_close == NGX_HTTP_LINGERING_ON
&& (r->lingering_close
|| r->header_in->pos < r->header_in->last
|| r->connection->read->ready)))
{
ngx_http_set_lingering_close(r);
return;
}
ngx_http_close_request(r, 0);
}

注意上面并不是 ngx_http_finalize_connection 函数的全部,我只是贴出了与问题相关的代码。可以看到 Nginx 在不需要维护长连接且开启了 lingering close 的时,会调用 ngx_http_set_lingering_close 来设置最终的关闭函数。单词 lingering 是延迟的意思,那么 lingering close 自然是延迟关闭的意思。熟悉 socket 编程的同学应该知道 socket 有一个选项叫 SO_LINGER,如果对一个套接字开启了该选项,那么在调用 close 或者 shutdown 关闭套接字时会一直阻塞到将缓冲区里的消息都发送完毕才能返回。开启该选项的主要作用是为了平滑关闭套接字,使服务具有更好的兼容性,更具体的内容大家可以网上查阅资料。前面说到如果直接在套接字上设置 SO_LINGER 属性,那么在关闭时可能会引起阻塞,可是我们又知道 Nginx 里的套接字都设置了非阻塞属性,这会导致未定义的行为,另外如果完全由操作系统来进行延迟关闭,可能并不能满足 Nginx 的需求,所以 Nginx 没有使用这种方法,而是自己实现了延迟关闭。首先看下 ngx_http_set_lingering_close 函数,它是用来对一个请求设置延迟关闭方法的:

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
ngx_http_set_lingering_close(ngx_http_request_t *r)
{
ngx_event_t *rev, *wev;
ngx_connection_t *c;
ngx_http_core_loc_conf_t *clcf;
c = r->connection;
clcf = ngx_http_get_module_loc_conf(r, ngx_http_core_module);
rev = c->read; //获取连接的读事件
//设置读事件触发时的处理函数,也就是延时关闭连接函数
rev->handler = ngx_http_lingering_close_handler;
//lingering_time用来控制总的延迟超时时间,比如在第一个lingering_timeout后,收到了数据,那么接下来还会再进行
//延迟关闭,然后再等待lingering_timeout,如此反复,但是总的时间不能超过lingering_time
r->lingering_time = ngx_time() + (time_t) (clcf->lingering_time / 1000);
//向事件循环中加入超时事件,超时时间是lingering_timeout,
//也就是说在lingering_timeout时间后,ngx_http_lingering_close_handler会被调用
ngx_add_timer(rev, clcf->lingering_timeout);
if (ngx_handle_read_event(rev, 0) != NGX_OK) {
ngx_http_close_request(r, 0);
return;
}
wev = c->write;
wev->handler = ngx_http_empty_handler;
if (wev->active && (ngx_event_flags & NGX_USE_LEVEL_EVENT)) {
if (ngx_del_event(wev, NGX_WRITE_EVENT, 0) != NGX_OK) {
ngx_http_close_request(r, 0);
return;
}
}
//关闭套接字的写端,也就是说只有读是延迟关闭的
if (ngx_shutdown_socket(c->fd, NGX_WRITE_SHUTDOWN) == -1) {
ngx_connection_error(c, ngx_socket_errno,
ngx_shutdown_socket_n " failed");
ngx_http_close_request(r, 0);
return;
}
if (rev->ready) {
ngx_http_lingering_close_handler(rev);
}
}

ngx_http_set_lingering_close 函数就是用过来设置延迟关闭函数的,关键的部分已经加了注释。可以看到 Nginx 主要通过 lingering_time 和 lingering_timeout 这两个参数来控制延迟关闭的时间,lingering_time 表示总的延迟时间,lingering_timeout 表示单次延迟时间。上面的这段代码会向 Nginx 的事件循环注册一个超时时间,超时的时间间隔是 lingering_timeout ,超时事件的处理函数是 ngx_http_lingering_close_handler,就是说一旦延迟时间到了,该函数就会被调用,它的主要内容如下:

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
ngx_http_lingering_close_handler(ngx_event_t *rev)
{
ssize_t n;
ngx_msec_t timer;
ngx_connection_t *c;
ngx_http_request_t *r;
ngx_http_core_loc_conf_t *clcf;
u_char buffer[NGX_HTTP_LINGERING_BUFFER_SIZE];
c = rev->data;
r = c->data;
ngx_log_debug0(NGX_LOG_DEBUG_HTTP, c->log, 0,
"http lingering close handler");
if (rev->timedout) {
ngx_http_close_request(r, 0);
return;
}
//计算剩余的全部可用超时时间
timer = (ngx_msec_t) r->lingering_time - (ngx_msec_t) ngx_time();
//总延迟等待时间已经超过lingering_time了,那么不管怎么样都直接关闭连接
if ((ngx_msec_int_t) timer <= 0) {
ngx_http_close_request(r, 0);
return;
}
do {
n = c->recv(c, buffer, NGX_HTTP_LINGERING_BUFFER_SIZE);
ngx_log_debug1(NGX_LOG_DEBUG_HTTP, c->log, 0, "lingering read: %z", n);
//延迟时间到了,且套接字发生了错误,或者对方关闭了套接字,那么将整个连接关闭
if (n == NGX_ERROR || n == 0) {
ngx_http_close_request(r, 0);
return;
}
} while (rev->ready);
if (ngx_handle_read_event(rev, 0) != NGX_OK) {
ngx_http_close_request(r, 0);
return;
}
clcf = ngx_http_get_module_loc_conf(r, ngx_http_core_module);
timer *= 1000;
if (timer > clcf->lingering_timeout) {
timer = clcf->lingering_timeout;
}
//运行到这里,说明超时时间内客户端发来了数据且还有超时时间可用,那么来再次注册延迟关闭事件,开始下一次的延迟关闭等待。
ngx_add_timer(rev, timer);
}

上面就是当延迟关闭事件超时后 Nginx 的处理过程,首先计算总的延迟超时时间还剩余多少,如果没有了,直接断开连接,这可以防止『等待-接收部分数据-等待-接收部分数据』的无限死循环。接下来 Nginx 尝试读取套接字,如果读出错或者对方关闭了连接或者依然没有数据读到,那么 Nginx 就将连接关闭,否则再次注册延迟超时事件,开始下一次的延迟关闭。根据上面的分析可以看到,在 Nginx 发送完数据包并进入延迟关闭连接流程后,如果客户端在 lingering_timeout 时间内没有进行任何操作,那么就会关闭与客户端的连接然后输出日志,这就会导致导致访问日志滞后 lingering_timeout 才输出。我们线上并没有对该参数进行配置,那么会采用默认值,正好是5秒,与实际情况吻合。另外如果使用长连接,Nignx 在请求结束后不需要关闭连接而直接输出日志,那么就不会有这个问题,这也就解释了为什么开启 Keep-Alive 后问题消失。

复现

知道了问题的原因复现就很简单了,只要在 Nginx 中设置 lingering_timeout 的值,然后观察日志中输出的时间差是不是发生相应的改变即可。比如将该值设置为7,会发现时间差为5的日志就消失了,而都变成了时间差为7的日志:

1
2
3
4
5
6
7
[shibing@localhost sbin]$ tail -f ../logs/access.log | grep "request_time=7"
172.17.176.138 - - [17/Nov/2016:18:53:15 +0800] "GET /index.php HTTP/1.1" 200 3450 "-" "-" "-" request_time=7.001 upstream_time=0.000 header_time=0.000
172.17.176.138 - - [17/Nov/2016:18:53:15 +0800] "GET /index.php HTTP/1.1" 200 3450 "-" "-" "-" request_time=7.000 upstream_time=0.000 header_time=0.000
172.17.176.138 - - [17/Nov/2016:18:53:15 +0800] "GET /index.php HTTP/1.1" 200 3450 "-" "-" "-" request_time=7.001 upstream_time=0.001 header_time=0.001
172.17.176.138 - - [17/Nov/2016:18:53:15 +0800] "GET /index.php HTTP/1.1" 200 3450 "-" "-" "-" request_time=7.000 upstream_time=0.000 header_time=0.000
172.17.176.138 - - [17/Nov/2016:18:53:15 +0800] "GET /index.php HTTP/1.1" 200 3450 "-" "-" "-" request_time=7.000 upstream_time=0.000 header_time=0.000
172.17.176.138 - - [17/Nov/2016:18:53:15 +0800] "GET /index.php HTTP/1.1" 200 3450 "-" "-" "-" request_time=7.000 upstream_time=0.000 header_time=0.000
Stone's Blog

nginx内存对齐

发表于 2016-09-30 |

最近在看Nginx的源代码,阅读其有关内存池实现的时候发现下面一段代码

1
2
3
if (align) {
m = ngx_align_ptr(m, NGX_ALIGNMENT);
}

这段代码的意图很明显,就是将指针m按照NGX_ALIGNMENT大小进行对齐,那么它是如何进行对齐的呢?关键就在ngx_align_ptr,它其实是个宏,定义如下:

1
2
#define ngx_align_ptr(p, a) \
(u_char *) (((uintptr_t) (p) + ((uintptr_t) a - 1)) & ~((uintptr_t) a - 1))

我刚看到这个宏的时候觉得很神奇,完全不知道它进行内存对齐的原理是什么。于是打算人肉运行一下这段代码,看能不能发现里面的玄机。

首先类型转换都去掉,这样代码能看的清楚一点:

1
(p + (a - 1)) & ~(a-1)

在进行内存的对齐的时候,一般都是将地址按2的幂进行对齐,也就是说a的末尾必然由0组成。假设有N个0,那么将a减去一后,就得到了一个末尾有N个1其他位都是0的数,再取反的话就变成了末尾有N个0,其他位都是1的数。那么再将这个数与任何数相与都将把被与数的最后N位变成0而其他位置不变,而这个数正是a的整数倍。那么加上a-1的目的是什么呢?很明显是为了最后得到的数要比p大,不然后的话,分配的内存就可能占用了别的已分配空间。

另外上面在进行运算之前将所有的变量都转换成了uintptr_t类型,该类型本质上也是个整型,但是该整型的大小能够安全的容纳指针变量,相比于直接用整数类型而言更加具有可移植性和安全性。

(完)

Stone's Blog

动态链接与rpath

发表于 2016-08-20 |

最近在工作中做php环境的绿色安装,所谓绿色安装是指将程序以及它的所有依赖包括一些共享库、配置文件等打包到一起然后进行安装的方式。因为所有的依赖都打包到了一起,那么部署的过程其实就是简单的拷贝过程,非常的方便。但是这之中遇到了一个问题,就是如何让php运行的时候加载我们绿色包里面的共享库,而不是加载系统自身的共享库。为此学习了linux下运行时链接器器搜索共享库的方式,发现通过指定RPATH可以很方便的解决这个问题。

RPATH

linux下在链接共享库的时候可以通过rpath选项来指定运行时共享库加载路径。 通过这个选项指定的路径会写到ELF文件dynamic段的RPATH里, 运行时链接器会在此路径下搜索ELF文件所依赖的共享库。

示例

假设我们有一个共享库libarith.so,提供了常见的算术运算,它由arith.h与arith.c两个文件编译生成。内容如下:

arith.h:

1
2
#pragma once
int add(int a, int b);

arith.c:

1
2
3
4
5
#include "arith.h"
int add(int a, int b)
{
return a + b;
}

生成so文件

1
$ gcc -fPIC -shared arith.c -o libarith.so

然后我们有一个main.c文件需要调用前面生成的共享库,内容如下:

1
2
3
4
5
#include "arith.h"
int main()
{
add(1, 2);
}

如果我们不指定rpath直接编译main.c:

1
$ gcc -L. -larith main.c -o main

编译后目录内容如下:

1
2
3
4
5
6
.
├── arith.c
├── arith.h
├── libarith.so
├── main
└── main.c

若此时运行main文件会报如下错误:

1
./main: error while loading shared libraries: libarith.so: cannot open shared object file: No such file or directory

报错提示找不到libarith.so文件。该文件在我们当前目录下,但是当前目录并不在运行时链接器的搜索路径中。一种解决办法是将当前路径添加到LD_LIBRARY_PATH中,但是该方法是一种全局配置,总是显得不那么干净。下面介绍第二种方法,就是在链接的时候直接将搜索路径写到RPATH中,按如下方式重新编译:

1
$ gcc -L. -larith main.c -Wl,-rpath='.' -o main

-rpath是链接器选项,并不是gcc的编译选项,所以上面通过-Wl,告知编译器将此选项传给下一阶段的链接器。重新编译后,采用readelf命令查看main文件的dynamic节,发现多了一个RPATH字段,且值就是我们前面设置的路径。

1
2
$ readelf -d main| grep PATH
0x000000000000000f (RPATH) Library rpath: [.]

再次尝试运行main文件会发现一切正常。

$ORIGIN

上面的解决办法还有一些小问题,RPATH指定的路径是相当于当前目录的,而不是相对于可执行文件所在的目录,那么当换一个目录再执行上面的程序,就会又报找不到共享库。解决这个问题的办法就是使用$ORIGIN变量,在运行的时候,链接器会将该变量的值用可执行文件所在的目录来替换,这样我们就又能相对于可执行文件来指定RPATH了。重新编译如下:

1
$ gcc -L. -larith main.c -Wl,-rpath='$ORIGIN/' -o main

patchelf命令

很多时候我们拿到的是编译好的二进制文件,这样我们就不能用前面的办法来指定RPATH了。幸好有patchelf这个小工具,它可以用来修改elf文件,用它修改main的RPATH的方法如下:

1
$ pathelf main --set-rpath='$ORIGIN/'

总结

上面介绍了RPATH的作用,如何在编译的时候通过相关参数指定RPATH,以及使用patchelf修改RPATH的方法。

Bing Shi

Bing Shi

7 日志
7 标签
GitHub
© 2017 Bing Shi
由 Hexo 强力驱动
主题 - NexT.Pisces