Goos-多线程协程实现简要

Catalogue
  1. 1. 简介
  2. 2. swoole的协程相关
    1. 2.1. 为什么无完整调度器协程
      1. 2.1.1. Hook php原生函数
      2. 2.1.2. 在api中埋点监测协程
    2. 2.2. 什么是栈复用
    3. 2.3. 为什么是无栈内存收缩
    4. 2.4. 为什么无抢占调度
  3. 3. golang的调度
  4. 4. 简要总结

简介

Goos 是一个借鉴Golang的多线程协程调度器的设计而利用C++实现的PHP扩展。笔者在接触golang的协程调度器的力量后不能自拔,感受到协程的魅力后也想试试能不能为php也实现这么炫酷的功能

Goos 目的是实现一个真正意义的单进程多线程协程调度器,充分利用多核,使动态语言也能高效的开发出高性能的服务。目前主要实现进展如下:

  • php环境线程隔离,协程隔离
  • 实现G-M调度,任意协程G创建后,自动绑定到线程M上去执行
  • 实现多线程协程G调度,切出与恢复
  • 优化php内存相关
  • 引入P, 实现G-P-M 任务窃取调度
  • 协程栈自动收缩,防止 stack overflow
  • 实现抢占调度,可以对任意在执行的协程发起抢占
  • 优化抢占调度,检查任意超过10ms持有G的线程,发起抢占调度

目前主要在优化内存方面的实现、引入P的实现、周边工具的开发(lock…)

接下来的其他文章将陆续讲解从底层至汇编指令-php应用层的整个实现过程

现在php其实也有许多相关扩展都带有协程实现的、如swoole。swoole和golang区别还是挺大的。下面来讲讲swoole和golang的简单区别吧

swoole的协程相关

swoole的协程为单进程协程,无完整调度器,只有触发了相关hook后才能切换,例如:swoole可以替换function_table中的sleep变成非阻塞,当调用sleep后直接切出当前协程

在内存方面swoole会申请c栈和php栈,基本上每个协程会占有2m的堆内存,且在协程销毁后该内存没有复用而是直接释放,因为swoole协程没有栈的收缩,所以需要注意在协程内的不要越栈Stack Overflow,否则system会给你一个segment error kill 进程。

综上建议不要什么都往协程上扔,针对这种协程机制需要严格考虑场景否则协程就是你的瓶颈。

总结一下swoole的协程机制:

1
2
3
4
5
6
7
8
9
- 无协程调度只能依赖hook原生函数实现切换

- 协程栈无复用导致频繁大内存申请释放

- 无栈内存收缩,当协程栈溢出后即致命错误

- 无抢占调度(发生for死循环将永远占用cpu)、

- 同步协程模型

上面的声明只是针对swoole的协程相关,因为swoole在多进程模式下也能充分利用多核cpu,弥补了一些不足,并且swoole的task-worker模型也做的足够出色了,可以轻松的实现一个多进程常驻通讯服务

为什么无完整调度器协程

我认为调度过程应该是一个底层的分配管理过程,就像linux的调度一样是一个更加底层的管理,无需用户去关注,而针对协程的调度目前主要有两个方面

协程让出:

1
2
3
4
一个正在运行的G,享有独立的栈空间,该栈是从堆上分配的一块独立内存来模拟栈行为。
且该独立的函数栈在执行过程中能够中断,能够暂停后被切出
就像我们的系统进程,线程一样,对于上层开发者来说是无感知的,
其实系统随时都在进行着切换

协程恢复

1
2
一个独立的函数栈因为在执行到一半的时候被调度出去了,那么在恢复的时候我们要能够让cpu继续执行在让出时的那条指令。
从而达到该暂停的函数能够继续向下执行,在执行完毕的时候要能够返回到我们正常的流程这里

具体协程的底层实现和流程我们会单独拿一个文章来说

swoole目前的协程是能够进行随意的切出和让出的,但是我想标注的点在于切出和让出的点应该有调度器来完全完成,而swoole目前是需要开发者具备协程的切出和恢复时机的

接下来讲讲swoole协程调度的点,swoole目前主要有两种实现来切换让出协程

  1. Hook php原生函数
  2. swoole相关api都加入检查是否需要切出

Hook php原生函数

在讲讲hook前,我们先说一下php函数的调用

1
2
3
4
5
6
7
8
php在脚本初始化阶段会初始化所有模块,并将对应模块函数 zend_function* 和php内置的函数存入一个全局EG(function_table)中,这个是一个hash表。
其实就是php里的数组底层的实现,然后key就是对应的php内置的函数名

那么能够想到value就是该函数的实际地址或者opcode汇编指令,这里不细说,因为php函数有好几种类型。
普通的c动态库扩展的函数就是一个函数指针指向扩展里的实际函数地址,而php内部函数比如用户自定义的函数可能就是一份编译过后的opcode码指令

在函数调用前(针对内置函数 比如sleep),会先去全局EG(function_table)->zend_hash_index(sleep)
查找是否存在,如果存在则获取对应的value,并设置对应的php函数栈帧信息,并执行

所以这里所说的hook,我们就可以理解为,在我们的php扩展里将全局函数替换为自己的自定函数,当用户执行sleep时

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
<?php
function co(){
sleep(10000);//实际被替换为协程版的sleep实现
}
go(co);

//--------------
demo.sleep.c
EG(function_table)->zend_hash_update_ptr("sleep",co_sleep);

void co_sleep(long sec)
{
timer.add(_g,sec);
_g.swap_out();
}
void timer_loop()
{

int n = epoll_wait(epfd,events,0);
for(....){
g = events[i];
//在timer定时器中在执行恢复该协程
g.swap_out()
}
}

实际执行的是一个协程切换的方法,并且将当前协程加入 timer中,等待epoll时间片到期后恢复

在api中埋点监测协程

这个容易想到,在swoole的新增api中,都会监测是否需要切入或者恢复,例如调用swoole协程客户端send,因为该tcp端点属性被设置为边缘模式,也就是如果没有就绪事件则不阻塞进程,而是直接返回EAGAIN,那么此时swoole就会将该G加入到epoll_add中管理,等待事件到来后 在恢复该执行

什么是栈复用

一个协程的准备环境是需要申请两次大内存,一个是php栈,一个是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
                         +--------------+
| |
+ | |
| +--------------+
| | |
| | arg(N-1) | starts from 7'th argument for x86_64
| | |
| +--------------+
| | |
| | argN |
| | |
| +--------------+
| | |
| |Return address| %rbp + 8
Stack grows down | | |
| +--------------+
| | |
| | %rbp | Frame base pointer
| | |
| +--------------+
| | |
| | local var1 | %rbp - 8
| | |
| +--------------+
| | |
| | local var 2 | <-- %rsp
| | |
v +--------------+
| |
| |
+--------------+

首先受限于寄存器数量的原因,大多数变量都是存储在在栈中,每次新调用一个函数,那么就会保存当前上下文变量到栈中,最后将当前指令地址 cs,ip(就是函数的返回地址)压栈,然后在jump到目的指令地址实现函数调用,新函数栈会在系统栈下面继续使用,不断从高地址往低地址增长,如果函数返回则低地址往高地址增长出栈。所以这就是为什么栈上的变量在函数退出后不能再使用了的原因(虽然变量不会被立即销毁,但是如果发生其他函数调用,则会复用该地址的数据,这样就会导致非法内存访问,发生难以排查的致命bug)

上面的图加上粗略的描述了函数栈后来讲讲为什么要用堆模拟栈:

协程也是一个函数,那为什么要另外申请一个堆内存来当做该函数的执行栈呢,而不用本身系统为当前进程分配的栈呢,正如上面讲的当前栈在函数退出后,会被其他函数栈给覆盖,那么当前函数的所有上下文和变量都变成了未知内存

所以想要支持协程函数的切换和恢复,那么肯定是需要一直保存该函数栈的上下文信息的,所以只能用堆内存来当做栈使用

让我们继续回到swoole栈复用的问题,因为每次协程创建都会申请8kphp栈和2mc栈,且协程释放后会销毁该内存,所有目前swoole会存在这种频繁申请和释放的浪费情况,因为swooole是进程模型所以协程是同步的,所以就算创建千万协程也是同步排队执行,不会导致内存飙升

为什么是无栈内存收缩

上面降了函数栈的模型,我们知道在函数内所有产生的栈变量都会压栈,不断的使用栈空间,但是这个栈是系统分配的栈空间,默认是可以达到进程上限

而我们要实现的协程是申请的一份堆内存来模拟的栈,所以大小一开始就固定好的且不会太大,当我们在协程内做了大量操作栈溢出后,就会触发堆溢出引发致命问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
高地址位 堆内存的末尾位置
┼───────────┼
│ 返回值g │
┼───────────┼
│ 返回值f │
┼───────────┼
│ 返回值e │
┼───────────┼
│ 参数之c │
┼───────────┼
│ 参数之b │
┼───────────┼
│ 参数之a │ <-- FP
┼───────────┼
│ PC │ <-- SP
┼───────────┼
|。。。。。 |

低地址位 堆内存的起始位置

swoole貌似目前在协程里跑大的数组进行遍历就会导致栈溢出,这就需要开发者在开发中小心这类问题

这就是无栈收缩会导致的问题,如果栈能够自动收缩,就无需考虑协程预分配大小,栈溢出等问题,既能节省内存也能是开发效率高效

为什么无抢占调度

何谓抢占调度,就是强制的被动触发的调度。在正常情况下发生调度都依赖于函数执行流遇到了阻塞或是主动让出才会触发切换。那抢占的意义又是什么呢,想象一下如下场景在swoole中使用

1
2
3
4
5
6
7
<?php

go(function(){
for(;;)
//do sth
//then break
});

  1. 如果上面的没有发生死循环,始终会等到某个条件中断该循环,那么依然会存在如下问题

    1
    2
    3
    4
    5
    6
    7
    8
    9
    如果该协程是在网络触发事件中被恢复的协程,那么自然能想到如下的情景
    for(;;){
    epoll_wait(...)

    //co->resume()
    }
    那么如果这时候上面那个协程执行的时间过长,都会直接影响其他流程的精度,比如timer是挂在epoll上触发的,那么定时器就会一直得不到执行,且网络事件也得不到执行

    这时候就会有其他意外产生
  2. 如果上面的协程发生死循环,永远不会中断

    1
    在目前没有抢占调度的情况下,自然阻塞当前进程,任何其他都得不到执行,基本就是死锁的样子了

这个时候就知道了抢占调度的意义了,监测每个协程的执行时间,严格控制时间,发生超时则调度该协程切出,这都是一个完整的协程调度器需要考虑的事情

golang的调度

golang 的调度就非常完整了,golang走的单进程多线程协程实现并发控制,swoole基于多进程同步协程实现并发控制,各有优缺点,我认为最大的区别就是

对于技术实现来说:

golang的多线程协程对于实现者来说非常复杂,而swoole的多进程同步协程对于实现者来说要相对友好,反正基于线程的实现都是异常恐怖的

对于技术使用者来说:

多线程的协程当然用起来要高效简单很多,无需关心进程通信等,而多进程对于开发者来说就稍微不友好一点

golang的协程和swoole比起来当然是完全不同的,golang走的多线程协程,所以几乎上面的特性都支持例如:

  1. 多线程调度
  2. 任务窃取器调度
  3. 抢占式调度
  4. 以及一些协程的优化:栈内存自动收缩
  5. 还有超多的协程生态工具链:lock,channel..等等

简要总结

上面的协程方面的设计确实还是和golang相差较大,swoole本身走的是多进程路线,同步协程只是为它在加了一份力,所以硬要和golang完整的单进程多线程协程比是没意义的

所以GOOS由此而来,想为php生效一个多线程协程版调度,从而使php既能保持动态语言编写代码的高效又能实现golang等静态语言的高性能并发控制