9

顺风车运营研发团队 李乐

1.消息队列简介

计算机软件发展的一个重要目标是降低软件耦合性;

网站架构中,系统解耦合的重要手段就是异步,业务之间的消息传递不是同步调用,而是将一个业务操作分为多个阶段,每个阶段之间通过共享数据的方式异步执行;

在分布式系统中,多个服务器集群通过分布式消息队列实现异步;分布式消息队列可以看作是内存队列的分布式部署;

分布式消息队列架构图通常如下所示:

clipboard.png

消息队列是典型的生产者消费者模式,两者不存在直接调用,只要保持数据结构不变,彼此功能实现可以随意改变而不互相影响;异步消息队列还有以下特点:

  • 提高系统可用性:消费者服务器发生故障时,生产者服务器可以继续处理业务请求,系统整体表现无故障;此时数据会在消息队列服务器堆积,待消费者服务器恢复后,可以继续处理消息队列中的数据;
  • 加快网站相应速度:业务处理前端的生产者服务器在处理完业务请求后,将数据写入消息队列,不需要等待消费者服务器处理就可以返回,减少响应延迟;
  • 消除并发访问高峰:用户访问是随机的,存在高峰和低谷;可以使用消息队列将突然增加的访问请求数据放入消息队列中,等待消费者服务器依次处理;

消费者消费消息时,通常有两种模式可以选择:拉模型与推模型。

  • 拉模型是由消息的消费者发起的,主动权把握在消费者手中,它会根据自己的情况对生产者发起调用;
  • 推模式消费者只会被动接受消息,消息队列一旦发现消息进入,就会通知消费者执行对消息的处理;

2.beanstalkd基本知识

2.1beanstalkd简介

beanstalkd是一个轻量级的消息队列;主要有一下特点:

  • 拉模式,消费者需要主动从服务器拉取消息数据;
  • tube:类似于消息主题topic,一个beanstalkd中可以支持多个tube,每个tube都有自己的producer和consumer;多个生产者可以往同一个tube生产job,多个消费者也能监听同一个tube获取job;
  • job:代替了传统的message,与消息最大的区别是,job有多种状态;
  • conn:代表一个客户端链接;
  • 优先级:job可以有0~2^32个优先级,0代表最高优先级,beanstalkd使用堆处理job的优先级排序,因此reserve命令的时间复杂度是O(logN);
  • 延时:生产者发布任务时可以指定延时,到达延迟时间后,job才能被消费者消费;
  • 超时机制:消费者从beanstalkd获取一个job后,必须在预设的 TTR (time-to-run) 时间内处理完任务,并发送 delete / release/ bury 命令改变任务状态;否则 Beanstalkd 会认为消息消费失败,重置job状态,使其可以被其他消费者消费。如果消费者预计在 TTR (time-to-run) 时间内无法完成任务, 也可以发送 touch 命令, 它的作用是让 Beanstalkd 从重新计时TTR;
  • 暂停:pause命令可以暂停当前tube,暂停时期内所有job都不能够被消费者消费;

job有一下几种状态:

  • READY,需要立即处理的任务,当延时 (DELAYED) 任务到期后会自动成为当前任务;
  • DELAYED,延迟执行的任务,;
  • RESERVED,已经被消费者获取, 正在执行的任务,Beanstalkd 负责检查任务是否在 TTR(time-to-run) 内完成;
  • BURIED,保留的任务: 任务不会被执行,也不会消失,除非有人将他修改为其他状态;
  • DELETED,消息被彻底删除。Beanstalkd 不再维持这些消息。

状态之间的转移图如下所示:

clipboard.png

思考:

  • beanstalkd如何维护job的状态?tube有3个集合delay、ready和 buried分别存放对应状态的job,conn的reserved_jobs集合存储状态为reserved的job(消费者获取一个job后,job的状态才会改变为reserved,因此这个集合由conn维护);
  • delay状态的job怎么修改为ready?delay集合是一个按照时间排序的最小堆,beanstalkd不定时循环从堆根节点获取job,校验是否需要改变其状态未ready;
  • 如何实现优先级?只有ready状态的job才能被消费者获取消费,ready集合是一个按照优先级排序的最小堆,根节点始终是优先级最高得job;
  • 拉模式实现?消费者使用reserve命令获取job,beanstalkd检查消费者监听的所有tube,查找到ready的job即返回,否则阻塞消费者知道有ready状态的job产生为止;

2.2beanstalkd命令

beanstalkd支持以下命令:

clipboard.png

3.beanstalkd源码分析

3.1数据结构

3.1.1基础结构体

//堆
struct Heap {
    int     cap; //堆容量
    int     len; //堆元素数目
    void    **data; //元素数组
    Less    less;   //元素比较的函数指针
    Record  rec;   //函数指针,将元素插入堆时,会调用此函数
};
 
//函数指针定义:
typedef int(*Less)(void*, void*);
typedef void(*Record)(void*, int);
 
//API:元素的插入与删除
void * heapremove(Heap *h, int k);
int heapinsert(Heap *h, void *x)
//集合
struct ms {
    size_t used, cap, last; //cap为当前集合容量;used集合中元素数目;last上次访问的集合元素的位置
    void **items; //存储元素的数组
    ms_event_fn oninsert, onremove; //往集合插入元素,删除元素时调用的函数
};
 
//函数指针定义如下
typedef void(*ms_event_fn)(ms a, void *item, size_t i);
 
//API
void ms_init(ms a, ms_event_fn oninsert, ms_event_fn onremove);//初始化集合
int ms_append(ms a, void *item) //往集合追加元素
int ms_contains(ms a, void *item)//判断集合是否包含元素
void * ms_take(ms a)  //获取并删除元素(会从上次访问的位置last开始查找)
int ms_remove(ms a, void *item) //删除元素,从头开始查找
int ms_clear(ms a) //清空集合

3.1.2 管道tube

 struct tube {
    uint refs; //引用计数
    char name[MAX_TUBE_NAME_LEN]; //名称
    Heap ready; //存储状态未ready的job,按照优先级排序
    Heap delay; //存储状态未delayed的job,按照到期时间排序
    struct ms waiting; //等待当前tube有job产生的消费者集合
    
    int64 pause; //执行pause命令后,pause字段记录暂停时间
    int64 deadline_at; //deadline_at记录暂停到达时间
    struct job buried; //存储状态为buried的job,是一个链表
};

创建tube的代码如下:

tube make_tube(const char *name)
{
    tube t;
 
    //底层调用malloc分配空间
    t = new(struct tube);
    if (!t) return NULL;
 
    t->name[MAX_TUBE_NAME_LEN - 1] = '\0';
    strncpy(t->name, name, MAX_TUBE_NAME_LEN - 1);
    if (t->name[MAX_TUBE_NAME_LEN - 1] != '\0') twarnx("truncating tube name");
 
    //设置ready与delay堆的函数指针
    t->ready.less = job_pri_less;
    t->delay.less = job_delay_less;
    t->ready.rec = job_setheappos;
    t->delay.rec = job_setheappos;
    t->buried = (struct job) { };
    t->buried.prev = t->buried.next = &t->buried;
    ms_init(&t->waiting, NULL, NULL);
 
    return t;
}

两个堆指针函数如下:

//按照优先级比较
int job_pri_less(void *ax, void *bx)
{
    job a = ax, b = bx;
    if (a->r.pri < b->r.pri) return 1;
    if (a->r.pri > b->r.pri) return 0;
    return a->r.id < b->r.id;
}
//按照过期时间比较
int job_delay_less(void *ax, void *bx)
{
    job a = ax, b = bx;
    if (a->r.deadline_at < b->r.deadline_at) return 1;
    if (a->r.deadline_at > b->r.deadline_at) return 0;
    return a->r.id < b->r.id;
}
//设置每个job在堆的index
void job_setheappos(void *j, int pos)
{
    ((job)j)->heap_index = pos;
}

3.1.3任务job

注:job创建完成后,先会保存在全局变量all_jobs的hash表中;然后才会插入到tube的各job队列中;

struct job {
    Jobrec r; // 存储job信息
 
    tube tube; //指向其所属tube
    job prev, next; //job可以组织为双向链表(buried状态的job就是链表)
    job ht_next; //所有的job都存储在一个hash表中(拉链法),job的id为hash值;(tube中的job集合存储指针指向各个job)
    size_t heap_index; /* where is this job in its current heap */
    …………
 
    char body[];//job的数据,柔性数组
};
 
// job的描述信息
struct Jobrec {
    uint64 id;
    uint32 pri;
    int64  delay;
    int64  ttr;
    int32  body_size;
    int64  created_at; //创建时间
    int64  deadline_at; //延迟job的过期时间
 
    //统计计数
    uint32 reserve_ct;
    uint32 timeout_ct;
    uint32 release_ct;
    uint32 bury_ct;
    uint32 kick_ct;
     
    byte   state;//当前状态
};

3.14套接字socket

struct Socket {
    int    fd;
    Handle f; //socket发生事件时的处理函数
    void   *x; //服务器监听的socket指向server结构体;客户端对应的socket指向conn结构体
    int    added; //往epoll注册事件时,计算操作类型
};

3.15服务器server

struct Server {
    char *port;
    char *addr;
     
    Socket sock; //监听的socket
    Heap   conns; //存储即将有事件发生的客户端;按照事件发生的时间排序的最小堆;
                  //例如:当客户端获取job后,若唱过TTR时间没处理完,job会状态应重置为ready状态;
                  //当客户端调用reserve获取job但当前tube没有ready状态的job时,客户端会被阻塞timeout时间;
};

3.1.6客户端链接conn

struct Conn {
    Server *srv; //执行服务器
    Socket sock; //客户端socket
    char   state; //客户端状态:等待接收命令,等待接收数据,等待回复命令,等待返回job,关闭,获取job阻塞中
    char   type;  //客户端类型:生产者,消费者,获取job阻塞中
    Conn   *next;
    tube   use;   //当前使用的tube;put命令发布的job会插入到当前tube中
    int64  tickat;      //客户端处理job的TTR到期时间;或者客户端阻塞的到期时间;用于在server的conns堆比较
    int    tickpos;     // 在srv->conns堆里的位置
    job    soonest_job; //所有reserve任务里到期时间最近的job
    int    rw;          //当前关心的事件: 'r', 'w', or 'h'(读、写、关闭连接)
    int    pending_timeout; //客户端获取job而阻塞的到期时间
    char   halfclosed; //表示客户端断开连接
 
    char cmd[LINE_BUF_SIZE]; // 输入缓冲区
    int  cmd_len;
    int  cmd_read;
 
    char *reply;   //输出缓冲区
    int  reply_len;
    int  reply_sent;
    char reply_buf[LINE_BUF_SIZE];
 
    //put命令发布job时,从客户端读入的job
    int in_job_read;
    job in_job;
 
    //待返回给客户端的job
    job out_job;
    int out_job_sent;
 
    //当前客户端监听的所有tube集合
    struct ms  watch;
    //当前客户端的所有reserved状态的job
    struct job reserved_jobs;
};

3.2 服务器启动过程

3.2.1 epoll简介

epoll结构体:

typedef union epoll_data {
    void *ptr;
    int fd;
    __uint32_t u32;
    __uint64_t u64;
} epoll_data_t;//保存触发事件的某个fd相关的数据
 
struct epoll_event {
    __uint32_t events;      /* epoll event */
    epoll_data_t data;      /* User data variable */
};
//其中events表示感兴趣的事件和被触发的事件,可能的取值为:
//EPOLLIN:表示对应的文件描述符可以读;
//EPOLLOUT:表示对应的文件描述符可以写;
//EPOLLPRI:表示对应的文件描述符有紧急的数可读;
//EPOLLERR:表示对应的文件描述符发生错误;
//EPOLLHUP:表示对应的文件描述符被挂断;

epoll API定义如下:

int epoll_create(int size) //生成一个epoll专用的文件描述符,其中的参数是指定生成描述符的最大范围;
 
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event) //用于控制某个文件描述符上的事件,可以注册事件,修改事件,删除事件
 
int epoll_wait(int epfd,struct epoll_event * events,int maxevents,int timeout)  //轮询I/O事件的发生;

3.2.2 beanstalkd使用epoll

//创建epoll:
epfd = epoll_create(1);
 
//注册事件
int sockwant(Socket *s, int rw)
{
   …………
   ev.events |= EPOLLRDHUP | EPOLLPRI;
   ev.data.ptr = s; //注意:传入的是sokcet指针;(socket的x字段会指向server或者conn结构体,当socket对应的fd发生事件时,可以得到server或conn对象)
 
   return epoll_ctl(epfd, op, s->fd, &ev);
}
 
//等待事件发生
//返回h r w 事件类型
int socknext(Socket **s, int64 timeout)
{
    …………
    r = epoll_wait(epfd, &ev, 1, (int)(timeout/1000000));
    if (r == -1 && errno != EINTR) {
        twarn("epoll_wait");
        exit(1);
    }
 
    if (r > 0) {
        *s = ev.data.ptr; //返回发生事件的socket
        if (ev.events & (EPOLLHUP|EPOLLRDHUP)) {
            return 'h';
        } else if (ev.events & EPOLLIN) {
            return 'r';
        } else if (ev.events & EPOLLOUT) {
            return 'w';
        }
    }
    return 0;
}

3.2.3服务器启动

int main(int argc, char **argv)
{
     
    optparse(&srv, argv+1);//解析输入参数
     
    r = make_server_socket(srv.addr, srv.port); //创建socket
    
    prot_init(); //初始化全局tubes集合,创建名称为default的默认tube
 
    srvserve(&srv);//启动服务器
    return 0;
}
 
struct ms tubes;//全局变量
 
void prot_init()
{
    //初始化tube集合
    ms_init(&tubes, NULL, NULL);
 
    //创建默认tube;tube_find_or_make方法会先从tubes集合查找指定名称为tube,查找到直接返回;否则创建新的tube
    TUBE_ASSIGN(default_tube, tube_find_or_make("default"));
}
 
 
void srvserve(Server *s)
{
    //s->sock为server监听的socket;设置其处理函数为srvaccept;
    s->sock.x = s;
    s->sock.f = (Handle)srvaccept;
    s->conns.less = (Less)connless; //设置s->conns堆的函数指针
    s->conns.rec = (Record)connrec;
 
    r = listen(s->sock.fd, 1024); //监听
     
    r = sockwant(&s->sock, 'r'); //注册到epoll
     
    //开启循环
    for (;;) {
        //服务器有一些事件需要在特定时间执行,获得最早待执行事件的时间间隔,作为epoll_wait的等待时间;后面详细分析函数内部
        period = prottick(s);
 
        int rw = socknext(&sock, period); //epoll wait
         
        if (rw) {
            sock->f(sock->x, rw); //调用socket的处理函数
        }
    }
}
//至此,服务器启动完毕,等待客户端链接

conns堆分析:

上面说过,conns存储即将有事件发生的客户端;按照事件发生的时间排序的最小堆;
例如:当客户端获取job后,若唱过TTR时间没处理完,job会状态应重置为ready状态;
当客户端调用reserve获取job但当前tube没有ready状态的job时,客户端会被阻塞timeout时间;

//堆节点比较的函数指针:
int connless(Conn *a, Conn *b)
{
    return a->tickat < b->tickat;
}
 
 
//将客户端对象插入conns堆时,tickpos记录其插入的index(避免客户端重复插入;插入之前发现其tickpos>-1则先删除再插入)
void connrec(Conn *c, int i)
{
    c->tickpos = i;
}

处理客户端链接请求:

void srvaccept(Server *s, int ev)
{
    h_accept(s->sock.fd, ev, s);
}
 
void h_accept(const int fd, const short which, Server *s)
{
     
    cfd = accept(fd, (struct sockaddr *)&addr, &addrlen);
     
    flags = fcntl(cfd, F_GETFL, 0); //获得fd标识
     
    r = fcntl(cfd, F_SETFL, flags | O_NONBLOCK); //设置fd非阻塞,使用epoll必须设置非阻塞,负责epoll无法同时监听多个fd
     
    //创建conn对象;默认监听default_tube(c->watch存储所有监听的tube);默认使用default_tube(c->use)
    //注意:初始化conn对象时,客户端状态为STATE_WANTCOMMAND,即等待接收客户端命令;
    c = make_conn(cfd, STATE_WANTCOMMAND, default_tube, default_tube);
     
    c->srv = s;
    c->sock.x = c;
    c->sock.f = (Handle)prothandle; //设置客户端处理函数
    c->sock.fd = cfd;
 
    r = sockwant(&c->sock, 'r'); //epoll注册,监听可读事件
}

当客户端socket可读或可写时,会执行prothandle函数:

static void prothandle(Conn *c, int ev)
{
    h_conn(c->sock.fd, ev, c);
}
 
static void h_conn(const int fd, const short which, Conn *c)
{
    //客户端断开链接,标记
    if (which == 'h') {
        c->halfclosed = 1;
    }
 
    //客户端数据交互(根据客户端状态不同执行不同的读写操作)
    conn_data(c);
    //解析完命令时,执行命令
    while (cmd_data_ready(c) && (c->cmd_len = cmd_len(c))) do_cmd(c);
    
}

3.3 服务器与客户端的数据交互

beanstalkd将客户端conn分为以下几种状态:等待接受命令,等待接收数据,等待回复数据,等待返回job等;

#define STATE_WANTCOMMAND 0
#define STATE_WANTDATA 1
#define STATE_SENDJOB 2
#define STATE_SENDWORD 3
#define STATE_WAIT 4
#define STATE_BITBUCKET 5
#define STATE_CLOSE 6

当客户端fd可读或者可写时,服务器根据当前客户端的状态执行不同的操作:

注意:TCP是基于流的,因此存在半包、粘包问题;即,服务器一次read的命令请求数据可能不完整,或者一次read多个命令请求的数据;

//有些状态操作已省略
static void conn_data(Conn *c)
{
     
    switch (c->state) {
    case STATE_WANTCOMMAND:
        r = read(c->sock.fd, c->cmd + c->cmd_read, LINE_BUF_SIZE - c->cmd_read); //读取命令到输入缓冲区cmd
        c->cmd_read += r;
 
        c->cmd_len = cmd_len(c); //定位\r\n,并返回命令请求开始位置到\r\n长度;如果没有\r\b说明命令请求不完全,返回0
 
        if (c->cmd_len) return do_cmd(c); //如果读取完整的命令,则处理;否则意味着命令不完全,需要下次继续接收
 
 
        break;
     
    case STATE_WANTDATA: //只有当使用put命令发布任务时,才会携带数据;客户端状态才会成为STATE_WANTDATA;
                         //而读取命令行时,已经携带了任务的必要参数,那时已经创建了任务,并存储在c->in_job字段
        j = c->in_job;
 
        r = read(c->sock.fd, j->body + c->in_job_read, j->r.body_size -c->in_job_read); //读取任务数据
         
        c->in_job_read += r; //记录任务读取了多少数据
 
        maybe_enqueue_incoming_job(c); //函数会判断任务数据是否已经读取完全,完全则将任务写入tube的ready或delay队列;后面会将
        break;
    case STATE_SENDWORD: //回复客户端命令请求
        r= write(c->sock.fd, c->reply + c->reply_sent, c->reply_len - c->reply_sent);
         
        c->reply_sent += r; //已经发送的字节数
 
        if (c->reply_sent == c->reply_len) return reset_conn(c); //如果返回数据已经发完,则重置客户端rw,关心可读事件;否则继续待发送数据状态
 
        break;
    case STATE_SENDJOB: //待发送job
        j = c->out_job;
 
        //返回数据与job
        iov[0].iov_base = (void *)(c->reply + c->reply_sent);
        iov[0].iov_len = c->reply_len - c->reply_sent; /* maybe 0 */
        iov[1].iov_base = j->body + c->out_job_sent;
        iov[1].iov_len = j->r.body_size - c->out_job_sent;
 
        r = writev(c->sock.fd, iov, 2);
        
        c->reply_sent += r;
        if (c->reply_sent >= c->reply_len) {
            c->out_job_sent += c->reply_sent - c->reply_len;
            c->reply_sent = c->reply_len;
        }
 
        if (c->out_job_sent == j->r.body_size) { //如果job的数据已经发完,则重置客户端rw,关心可读事件;否则继续待发送job
            return reset_conn(c);
        }
        break;
    
    }
}

3.4 命令的处理过程

3.4.1查找命令

//命令执行的入口函数
static void do_cmd(Conn *c)
{
    dispatch_cmd(c);  //分发并执行命令
    fill_extra_data(c); //put命令时,不仅需要执行命令,还需要接续job数据
}
 
static void
dispatch_cmd(Conn *c)
{
    //查找命令类型
    type = which_cmd(c);
 
    //switch处理各个命令
    switch (type) {
        …………
    }
}

beanstalkd有以下命令定义:

//命令字符串
#define CMD_PUT "put "
#define CMD_PEEK_READY "peek-ready"
#define CMD_RESERVE "reserve"
#define CMD_RELEASE "release "
…………
 
//命令编码类型:
#define OP_UNKNOWN 0
#define OP_PUT 1
#define OP_PEEKJOB 2
#define OP_RESERVE 3
#define OP_DELETE 4
#define OP_RELEASE 5
…………

查找命令其实就是字符串比较:

static int which_cmd(Conn *c)
{
    //宏定义;比较输入缓冲区命令字符串与命令表中字符串比较,返回命令类型
    #define TEST_CMD(s,c,o) if (strncmp((s), (c), CONSTSTRLEN(c)) == 0) return (o);
 
    //宏替换后就是一系列if语句
    TEST_CMD(c->cmd, CMD_PUT, OP_PUT);
    TEST_CMD(c->cmd, CMD_PEEKJOB, OP_PEEKJOB);
    …………
}

3.4.2命令1——发布任务

case OP_PUT:
    r = read_pri(&pri, c->cmd + 4, &delay_buf); //解析优先级pri
 
    r = read_delay(&delay, delay_buf, &ttr_buf); //解析delay
  
    r = read_ttr(&ttr, ttr_buf, &size_buf); //解析ttr
 
    body_size = strtoul(size_buf, &end_buf, 10); //解析job字节数
   
    op_ct[type]++; //统计
 
    if (body_size > job_data_size_limit) { //job长度超过限制;返回
        return skip(c, body_size + 2, MSG_JOB_TOO_BIG);
    }
 
   //put,说明是生产者,设置conn类型为生产者
    connsetproducer(c);
 
    //初始化job结构体,存储在hash表all_jobs中
    c->in_job = make_job(pri, delay, ttr, body_size + 2, c->use);
 
    //解析客户端发来的任务数据,存储在c->in_job的body数据字段
    fill_extra_data(c);
 
    //校验job数据是否读取完毕,完了则入tube的队列
    maybe_enqueue_incoming_job(c);

任务入队列:

static void maybe_enqueue_incoming_job(Conn *c)
{
    job j = c->in_job;
 
    //任务数据已经读取完毕,入队列(ready或者delay队列)
    if (c->in_job_read == j->r.body_size) return enqueue_incoming_job(c);
 
    //任务数据没有读取完毕,则设置客户端conn状态未等待接收数据STATE_WANTDATA
    c->state = STATE_WANTDATA;
}
 
static void enqueue_incoming_job(Conn *c)
{
    int r;
    job j = c->in_job;
 
    c->in_job = NULL; /* the connection no longer owns this job */
    c->in_job_read = 0;
     
    //入队列
    r = enqueue_job(c->srv, j, j->r.delay, 1);
     
    //返回数据;并设置conn状态为STATE_SENDWORD
    reply_line(c, STATE_SENDWORD, MSG_BURIED_FMT, j->r.id);
}
 
static int enqueue_job(Server *s, job j, int64 delay, char update_store)
{
    int r;
 
    j->reserver = NULL;
    if (delay) {   //入delay队列,设置任务的deadline_at
        j->r.deadline_at = nanoseconds() + delay;
        r = heapinsert(&j->tube->delay, j);
        
        j->r.state = Delayed;
    } else {      //入ready队列
        r = heapinsert(&j->tube->ready, j);
        if (!r) return 0;
        j->r.state = Ready;  
    }
 
    //检查有没有消费者正在阻塞等待此tube产生job,若有需要返回job;
    process_queue();
    return 1;
}

返回命令回复给客户端:

//reply_line函数组装命令回复数据,调用reply函数;只是将数据写入到输出缓冲区,并修改了客户端状态为STATE_SENDWORD,实际发送数据在3.3节已经说过;
static void reply(Conn *c, char *line, int len, int state)
{
    if (!c) return;
 
    connwant(c, 'w');//修改关心的事件为可写事件
    c->next = dirty; //放入dirty链表
    dirty = c;
    c->reply = line; //输出数据缓冲区
    c->reply_len = len;
    c->reply_sent = 0;
    c->state = state; //设置conn状态
}

connwant函数实现如下:

void connwant(Conn *c, int rw)
{
    c->rw = rw; //c->rw记录当前客户端关心的socket事件
    connsched(c);
}
 
 
void connsched(Conn *c)
{
    if (c->tickpos > -1) { //c->tickpos记录当前客户端在srv->conns堆的索引;(思考:tickpos在什么时候赋值的?heap的函数指针rec)
        heapremove(&c->srv->conns, c->tickpos);
    }
    c->tickat = conntickat(c); //计算当前客户端待发生的某个事件的时间
    if (c->tickat) {
        heapinsert(&c->srv->conns, c); //插入srv->conns堆
    }
}

问题1:connwant只是修改了conn的rw字段为‘w’,表示关心客户端的读时间,什么时候调用epoll注册呢?dirty链表又是做什么的呢?

beanstalkd有个函数update_conns负责更新客户端socket的事件到epoll;其在每次循环开始,执行epoll_wait之前都会执行;

static void update_conns()
{
    int r;
    Conn *c;
 
    while (dirty) { //遍历dirty链表,更新每一个conn关心的socket事件
        c = dirty;
        dirty = dirty->next;
        c->next = NULL;
        r = sockwant(&c->sock, c->rw);
        if (r == -1) {
            twarn("sockwant");
            connclose(c);
        }
    }
}

问题2:srv->conns存储的客户端都是在某个时间点有事件待处理的,客户端都有哪些事件需要处理呢?

  • 1)消费者获取job后,job的状态改为reserved,当TTR时间过后,如果客户端还没有处理完这个job,服务器会将这个job的状态重置为ready,以便让其他消费者可以消费;
  • 2)消费者调用reserve获取job时,假如其监听的tube没有ready状态的job,那么客户端将会被阻塞,直到有job产生,或者阻塞超时;
//计算当前客户端待处理事件的deadline
static int64 conntickat(Conn *c)
{
    //客户端正在阻塞
    if (conn_waiting(c)) {
        margin = SAFETY_MARGIN;
    }
 
    //如果客户端有reserved状态的任务,则获取到期时间最近的;(当客户端处于阻塞状态时,应该提前SAFETY_MARGIN时间处理此事件)
    //connsoonestjob:获取到期时间最近的reserved job
    if (has_reserved_job(c)) {
        t = connsoonestjob(c)->r.deadline_at - nanoseconds() - margin;
        should_timeout = 1;
    }
    //客户端阻塞超时时间
    if (c->pending_timeout >= 0) {
        t = min(t, ((int64)c->pending_timeout) * 1000000000);
        should_timeout = 1;
    }
 
    //返回时间发生的时间;后续会将此客户端插入srv->conns堆,且是按照此时间排序的;
    if (should_timeout) {
        return nanoseconds() + t;
    }
    return 0;
}

问题3:当生产者新发布一个job到某个tube时,此时可能有其他消费者监听此tube,且阻塞等待job的产生,此时就需要将此job返回给消费者;处理函数为process_queue

static void process_queue()
{
    int64 now = nanoseconds();
 
    while ((j = next_eligible_job(now))) { //遍历所有tube,当tube有客户端等待,且有ready状态的job时,返回job
        heapremove(&j->tube->ready, j->heap_index);
         
        //ms_take:将客户端从此job所属tube的waiting集合中删除;并返回客户端conn
        //remove_waiting_conn:从当前客户端conn监听的所有tube的waiting队列中移除自己
        //reserve_job:返回此job给客户端
        reserve_job(remove_waiting_conn(ms_take(&j->tube->waiting)), j);
    }
}
 
static job next_eligible_job(int64 now)
{
    tube t;
    size_t i;
    job j = NULL, candidate;
 
    //循环所有tube
    for (i = 0; i < tubes.used; i++) {
        t = tubes.items[i];
        if (t->pause) { //假如tube正在暂停,且超时时间未到,则跳过
            if (t->deadline_at > now) continue;
            t->pause = 0;
        }
        if (t->waiting.used && t->ready.len) { //tube的waiting集合有元素说明有客户端正在阻塞等待此tube产生任务;有ready状态的任务
            candidate = t->ready.data[0];   //从tubes里获取满足条件的优先级最高得job返回
            if (!j || job_pri_less(candidate, j)) {
                j = candidate;
            }
        }
    }
 
    return j;
}
 
 
Conn * remove_waiting_conn(Conn *c)
{
    tube t;
    size_t i;
 
    if (!conn_waiting(c)) return NULL;
 
    c->type &= ~CONN_TYPE_WAITING; //去除CONN_TYPE_WAITING标志
    global_stat.waiting_ct--;
    for (i = 0; i < c->watch.used; i++) {  //遍历客户端监听的所有tube,挨个从tube的waiting队列中删除自己
        t = c->watch.items[i];
        t->stat.waiting_ct--;
        ms_remove(&t->waiting, c);
    }
    return c;
}
 
 
static void reserve_job(Conn *c, job j)
{
    j->r.deadline_at = nanoseconds() + j->r.ttr; //job的实效时间
    
    j->r.state = Reserved; //状态改为Reserved
    job_insert(&c->reserved_jobs, j); //插入客户端的reserved_jobs链表
    j->reserver = c; //记录job当前消费者
    
    if (c->soonest_job && j->r.deadline_at < c->soonest_job->r.deadline_at) { //soonest_job记录最近要到期的Reserved状态的job,更新;
        c->soonest_job = j;
    }
    return reply_job(c, j, MSG_RESERVED); //返回job
}

3.4.3 命令2——获取任务reserve

case OP_RESERVE_TIMEOUT:
     
    timeout = strtol(c->cmd + CMD_RESERVE_TIMEOUT_LEN, &end_buf, 10); //reserve可以设置阻塞超时时间,解析
     
case OP_RESERVE:
 
    op_ct[type]++;
    connsetworker(c); //设置客户端类型为消费者CONN_TYPE_WORKER
 
    //当客户端有多个任务正在处理,处于reserved状态,且超时时间即将到达时;如果此时客户端监听的所有tube都没有ready状态的任务,则直接返回MSG_DEADLINE_SOON给客户端
    if (conndeadlinesoon(c) && !conn_ready(c)) {
        return reply_msg(c, MSG_DEADLINE_SOON);
    }
 
    //设置当前客户端正在等待job
    wait_for_job(c, timeout);
 
    //同3.4.2节
    process_queue();

上面说过,当客户端有多个任务正在处理,处于reserved状态,且超时时间即将到达时;

如果此时客户端监听的所有tube都没有ready状态的任务,则直接返回MSG_DEADLINE_SOON给客户端;

否则会导致客户端的阻塞,导致这些reserved的任务超时;

static void wait_for_job(Conn *c, int timeout)
{
    c->state = STATE_WAIT; //设置客户端状态为STATE_WAIT
    enqueue_waiting_conn(c); //将客户端添加到其监听的所有tube的waiting队列中
 
    //设置客户端的超时时间
    c->pending_timeout = timeout;
 
    //修改关心的事件为可读事件
    connwant(c, 'h');
    c->next = dirty; //将当前客户端添加到dirty链表中
    dirty = c;
}
 
static void enqueue_waiting_conn(Conn *c)
{
    tube t;
    size_t i;
 
    global_stat.waiting_ct++;
    c->type |= CONN_TYPE_WAITING;
    for (i = 0; i < c->watch.used; i++) {   //c->watch为客户端监听的所有tube
        t = c->watch.items[i];
        t->stat.waiting_ct++;
        ms_append(&t->waiting, c);   //t->waiting为等待当前tube有任务产生的所有客户端
    }
}

3.4.4 循环之始epoll_wait之前

在执行epoll_wait之前,需要计算超时时间;不能被epoll_wait一直阻塞;服务器还有很多事情待处理;

  • 1)将状态未delay的且已经到期的job移到ready队列;
  • 2)tube暂停时间到达,如果tube存在消费者阻塞等待获取job,需要返回job给客户端;
  • 3)消费者消费的状态为reserved的job可能即将超时到期;
  • 4)客户端阻塞等待job的超时时间可能即将达到;

服务器需要及时处理这些所有事情,因此epoll_wait等待时间不能过长;

int64 prottick(Server *s)
{
     
    int64 period = 0x34630B8A000LL; //默认epoll_wait等待时间
   
    now = nanoseconds();
    while ((j = delay_q_peek())) {  //遍历所有tube的delay队列中过期时间已经到达或者即将的job(即将到达时间最小)
        d = j->r.deadline_at - now;
        if (d > 0) {
            period = min(period, d); //即将到达,更新period
            break;
        }
        j = delay_q_take();
        r = enqueue_job(s, j, 0, 0); //job入队到ready队列
        if (r < 1) bury_job(s, j, 0); /* out of memory, so bury it */
    }
 
    for (i = 0; i < tubes.used; i++) {
        t = tubes.items[i];
        d = t->deadline_at - now;
        if (t->pause && d <= 0) { //tube暂停期限达到,process_queue同3.4.2节
            t->pause = 0;
            process_queue();
        }
        else if (d > 0) {
            period = min(period, d); //tube暂停即将到期,更新period
        }
    }
 
    while (s->conns.len) {
        Conn *c = s->conns.data[0]; //循环获取conn待执行事件发生时间最早的
        d = c->tickat - now;
        if (d > 0) {  //发生事件未到,更新period,结束循环
            period = min(period, d);
            break;
        }
 
        heapremove(&s->conns, 0); //否则,移除conn,处理客户端事件
        conn_timeout(c);
    }
 
    update_conns(); //更新客户端关心的socke事件,其实就是遍历dirty链表
 
    return period;
}
 
static job delay_q_peek()
{
    int i;
    tube t;
    job j = NULL, nj;
 
    for (i = 0; i < tubes.used; i++) {  //返回状态为delay且到期时间最小的job
        t = tubes.items[i];
        if (t->delay.len == 0) {
            continue;
        }
        nj = t->delay.data[0];
        if (!j || nj->r.deadline_at < j->r.deadline_at) j = nj;
    }
 
    return j;
}
 
static void conn_timeout(Conn *c)
{
    int r, should_timeout = 0;
    job j;
 
    //客户端正在被阻塞时,如果有reserved状态的job即将到期,则需要解除客户端阻塞
    //conndeadlinesoon:查询到期时间最小的reserved job,校验其是否即将到期(1秒内到期)
    if (conn_waiting(c) && conndeadlinesoon(c)) should_timeout = 1;
 
   //connsoonestjob获取到期时间最近的reserved job
    while ((j = connsoonestjob(c))) {
        if (j->r.deadline_at >= nanoseconds()) break;
 
        timeout_ct++; //已经超时
        j->r.timeout_ct++;
        r = enqueue_job(c->srv, remove_this_reserved_job(c, j), 0, 0); //从客户端的reserved_jobs链表移除job,重新入到tube的相应job队列
        if (r < 1) bury_job(c->srv, j, 0); /* out of memory, so bury it */
        connsched(c); //重新计算conn待处理事件的时间,入srv->conns堆
    }
 
    if (should_timeout) {
        return reply_msg(remove_waiting_conn(c), MSG_DEADLINE_SOON); //reserved即将到期,解除阻塞,返回MSG_DEADLINE_SOON消息
    } else if (conn_waiting(c) && c->pending_timeout >= 0) { //客户端阻塞超时,解除阻塞
        c->pending_timeout = -1;
        return reply_msg(remove_waiting_conn(c), MSG_TIMED_OUT);
    }
}

4、总结

本文主要介绍beanstalkd基本设计思路;从源码层次分析主要数据结构,服务器初始化过程,简要介绍了put和reserve两个命令执行过程;

beanstalkd其他的命令就不再介绍了,基本类似,感兴趣的可以自己研究。


AI及LNMPRG研究
7.2k 声望12.8k 粉丝

一群热爱代码的人 研究Nginx PHP Redis Memcache Beanstalk 等源码 以及一群热爱前端的人


引用和评论

0 条评论