skynet消息调度机制

本文主要参考&转载:skynet源码赏析

云风的 BLOG: skynet Archives

本文会讨论skynet的消息派发和消费,以及它是如何实现线程安全的,我们先从理解下面四种锁开始:

  • 互斥锁(mutex lock)
    • 互斥锁是一种用于多线程编程中,防止两条线程同时对同一公共资源进行读写的机制。
    • 互斥锁加锁失败后,线程会释放 CPU 给其他线程,自身阻塞并进入休眠状态。
  • 自旋锁(spin lock)
    • 自旋锁是用于多线程同步的一种,线程反复检查锁变量是否可用。由于线程在这一过程中保持执行,因此是一种忙等待。一旦获取了自旋锁,线程会一直保持该锁,直至显式释放自旋锁。
    • 自旋锁加锁失败后,线程会忙等待,自身阻塞但不断轮询加锁,直到它拿到锁。
  • 读写锁
    • 读写锁是实现并发控制的一种同步机制,也称“共享-互斥锁”,用于解决读写问题。读操作可并发重入,写操作是互斥的。这意味着多个线程可以同时读数据,但写数据时需要获得一个独占的锁。

    • 读写锁可以有不同的操作模式优先级:

      •  读操作优先锁:提供了最大并发性,但在锁竞争比较激烈的情况下,可能会导致写操作饥饿。这是由于只要还有一个读线程持锁,写线程就拿不到锁。多个读者可以立刻拿到锁,这意味着一个写者可能一直在等锁,期间新的读者一直可以拿到锁。极端情况下,写者线程可能会一直等锁,直到所有一开始就拿到锁的读者释放锁。读者的可以是弱优先级的,如前文所述,也可以是强优先级的,即只要写者释放锁,任何等待的读者总能先拿到。
      •  写操作优先锁:如果队列中有写者在等锁,则阻止任何读者拿锁,来避免了写操作饥饿的问题。一旦所有已经开始的读操作完成,等待的写操作立即获得锁。和读操作优先锁相比,写操作优先锁的不足在于在写者存在的情况下并发度低。内部实现需要两把互斥锁。
      •  未指定优先级锁:不提供任何读/写的优先级保证。
    • 读写锁通常用互斥锁条件变量信号量实现。

  • 条件变量
    •  假设A,B,C三条线程,其中B,C线程加了cond_wait锁并投入睡眠,而A线程则在某个条件触发时,会通过signal通知B,C线程,从而唤醒B和C线程

 消息传递

        skynet在启动时,会创建若干条worker线程(由配置指定),这些worker线程被创建以后,会不断得从global_mq里pop出一个次级消息队列来,每个worker线程,每次只pop一个次级消息队列,然后再从次级消息队列中,pop一到若干条消息出来(受权重值影响),最后消息将作为参数传给对应服务的callback函数(每个服务只有一个专属的次级消息队列),当callback执行完时,worker线程会将次级消息队列push回global_mq里,这样就完成了消息的消费。
        在这个过程中,因为每个worker线程会从global_mq里pop一个次级消息队列出来,此时其他worker线程就不能从global_mq里pop出同一个次级消息队列,也就是说,一个服务不能同时在多个worker线程内调用callback函数,从而保证了线程安全。

线程创建

要理解skynet的消息调度,首先要理解worker线程的创建流程,以及基本运作、线程安全。worker线程的数量由配置的“thread”字段指定,skynet节点启动时,会创建配置指定数量的worker线程,我们可以再skynet_start.c的start函数中找到这个创建流程:

// skynet_start.c
static void
start(int thread) {
	pthread_t pid[thread+3];

	struct monitor *m = skynet_malloc(sizeof(*m));
	memset(m, 0, sizeof(*m));
	m->count = thread;
	m->sleep = 0;

	m->m = skynet_malloc(thread * sizeof(struct skynet_monitor *));
	int i;
	for (i=0;i<thread;i++) {
		m->m[i] = skynet_monitor_new();
	}
	if (pthread_mutex_init(&m->mutex, NULL)) {
		fprintf(stderr, "Init mutex error");
		exit(1);
	}
	if (pthread_cond_init(&m->cond, NULL)) {
		fprintf(stderr, "Init cond error");
		exit(1);
	}

	create_thread(&pid[0], thread_monitor, m);
	create_thread(&pid[1], thread_timer, m);
	create_thread(&pid[2], thread_socket, m);

	static int weight[] = { 
		-1, -1, -1, -1, 0, 0, 0, 0,
		1, 1, 1, 1, 1, 1, 1, 1, 
		2, 2, 2, 2, 2, 2, 2, 2, 
		3, 3, 3, 3, 3, 3, 3, 3, };
	struct worker_parm wp[thread];
	for (i=0;i<thread;i++) {
		wp[i].m = m;
		wp[i].id = i;
		if (i < sizeof(weight)/sizeof(weight[0])) {
			wp[i].weight= weight[i];
		} else {
			wp[i].weight = 0;
		}
		create_thread(&pid[i+3], thread_worker, &wp[i]);
	}

	for (i=0;i<thread+3;i++) {
		pthread_join(pid[i], NULL); 
	}

	free_monitor(m);
}

        skynet所有的线程创建都在这里,在创建完moniter线程、time线程、socket线程之后,就开始创建worker线程。每个worker线程都会指定一个权重,这个权重决定一条线程一次消费多少条次级消息队列里的消息,当权重值小于0,worker线程一次消费一条消息;当权重值等于0,worker线程一次消费完次级消息队列里的所有消息;当权重值>0,假设次级消息队列的长度为mq_length,将mq_length转成二进制之后,向右移动weight位,其结果就是线程一次消费消息队列的消息数。

在多条线程,同时运作时,每条worker线程都要从global_mq中pop一条次级消息队列出来,对global_mq进行pop和push操作的时候,会用自旋锁锁住临界区。

// skynet_mq.c
void 
skynet_globalmq_push(struct message_queue * queue) {
	struct global_queue *q= Q;

	SPIN_LOCK(q)
	assert(queue->next == NULL);
	if(q->tail) {
		q->tail->next = queue;
		q->tail = queue;
	} else {
		q->head = q->tail = queue;
	}
	SPIN_UNLOCK(q)
}

struct message_queue * 
skynet_globalmq_pop() {
	struct global_queue *q = Q;

	SPIN_LOCK(q)
	struct message_queue *mq = q->head;
	if(mq) {
		q->head = mq->next;
		if(q->head == NULL) {
			assert(mq == q->tail);
			q->tail = NULL;
		}
		mq->next = NULL;
	}
	SPIN_UNLOCK(q)

	return mq;
}

这样出队操作,只能在一条worker线程中进行,而其他worker线程只能进入阻塞状态,在开的worker线程很多的情况下,始终有一定数量的线程处于阻塞状态,降低服务器的并发处理效率。按照上述的权重来看,第1-4条worker线程,每次只消费一个消息,第5-8条线程一次消费整条次级消费队列的消息,第9-16条worker线程一次消费整条次级消费队列的一半,第17-24则是四分之一,第25-32则是八分之一。这样做的目的,大概是希望避免过多的worker线程为了等待spin lock而陷入阻塞状态(因为一些线程,一次消费多条甚至全部次级消息队列的消息,因此在消费期间,不会对global_mq进行入队和出队操作,因此就不会尝试去访问spinlock锁住的临界区,该线程就在相当一段时间内不会陷入阻塞),进而提升服务器的并发处理能力。还有一点值得注意的是,前四条线程每次只从次级消息队列里面pop出一个消息,这样也在一定程度上保证了没有服务会被饿死。

一个worker线程创建出来之后,会不断的从global_mq中pop出一个次级消息队列,并从次级消息队列中pop出消息,进而通过服务的callback函数来消费该消息。

整个worker线程的流程是:

a) worker线程每次,从global_queue 中弹出一个次级消息队列,如果次级消息队列为空,则该worker线程投入睡眠,timer线程每隔2.5毫秒会唤醒一条睡眠中的线程,并重新尝试从全局队列中pop出一个次级消息队列,当次级消息队列不为空时,进行下一步

b) 根据次级消息队列的handle,找到其所属服务的指针(一个skynet_context)实例,从次级消息队列中pop出n条消息(受weight值影响),并且将其作为参数传递给skynet_context的cb函数,并调用它

c) 当完成callback函数的调用时,从global_queue中pop出一个次级消息队列,供下一次使用,并将本次使用的次级消息队列push回global_queue的尾部

d) 返回第a步

线程安全

  1. 整个消费流程,每条worker线程从global_queue中取出的次级消息队列都是唯一的,并且有且只有一个服务与之对应,取出之后,在该worker线程完成所有callback之前,不会push回global_mq中,也就是说,在这段时间其他worker线程不能拿到这个次级消息队列对应的服务,即一个服务不可能在多条worker线程内执行callback函数,从而保证了线程安全。
  2. 不论是全局消息队列还是次级消息队列,它们在入队和出队的时候都会加上spinlock,这样多个线程同时访问mq的时候,第一个访问者会进入临界区并锁住,其他线程会阻塞等待,直到该锁解除,这样也保证了线程安全。虽然一个服务的callback函数只能在一个worker线程内被调用,但是在多个worker线程内,可以向同一个次级消息队列push消息,即使是该次级消息队列所对应的服务正在执行callback函数,由于次级消息队列不是skynet_context的成员(skynet_context只是包含了该次级消息队列的指针),因此改变次级消息队列不等于改变skynet_context上的数据,不会影响到该服务自身内存的数据,次级消息队列在进行push和pop操作的时候,会加上一个spinlock,当多个worker线程同时向同一个次级消息队列push消息时,第一个访问的worker线程,能够进入临界区,其他worker线程就阻塞等待,直到该临界区解锁,这样保证了线程安全。
  3. 通过skynet_context列表获取skynet_context的过程中,需要加上一个读写锁:
    // skynet_handle.c
    struct skynet_context * 
    skynet_handle_grab(uint32_t handle) {
    	struct handle_storage *s = H;
    	struct skynet_context * result = NULL;
    
    	rwlock_rlock(&s->lock);
    
    	uint32_t hash = handle & (s->slot_size-1);
    	struct skynet_context * ctx = s->slot[hash];
    	if (ctx && skynet_context_handle(ctx) == handle) {
    		result = ctx;
    		skynet_context_grab(result);
    	}
    	rwlock_runlock(&s->lock);
    
    
    	return result;
    }

    其意义在于,多个worker线程同时从skynet_context列表获取指针时,没有一条线程是会被阻塞的,这样提高了并发效率,而此时,尝试往skynet_context列表添加新的服务的线程会被阻塞,因为添加新服务可能导致skynet_context列表大小被resize,因此读的时候不允许写入,写的时候不允许读取,保证了线程安全。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值