Erlang底层的定时器实现位于源码的erts/emulator/beam/time.c文件,用时间轮的方式动态添加和删除定时器,结构体名为typedef struct ErtsTimerWheel_ ErtsTimerWheel,每一个定时器的结构体名为typedef struct erl_timer ErtsTWheelTimer,看结构体实现大体上可以知道定时器的设计。

  1. 定时器 ErtsTWheelTimer

    typedef struct erl_timer {struct erl_timer* next; /* next entry tiw slot or chain */struct erl_timer* prev; /* prev entry tiw slot or chain */union {struct {void (*timeout)(void*); /* called when timeout */void (*cancel)(void*);  /* called when cancel (may be NULL) */void* arg;              /* argument to timeout/cancel procs */} func;ErtsThrPrgrLaterOp cleanup;} u;ErtsMonotonicTime timeout_pos; /* Timeout in absolute clock ticks */int slot;
    } ErtsTWheelTimer;
    

    每个定时器维护了前后向指针,有定时器到时作为回调的函数、取消定时器所调用的函数(可能做参数销毁用)和函数参数,还有定时器的到时点,以及此定时器位于时间轮的槽。

  2. 时间轮 ErtsTimerWheel

    struct ErtsTimerWheel_ {ErtsTWheelTimer *w[ERTS_TIW_SIZE];ErtsMonotonicTime pos;Uint nto;struct {ErtsTWheelTimer *head;ErtsTWheelTimer *tail;Uint nto;} at_once;int yield_slot;int yield_slots_left;int yield_start_pos;ErtsTWheelTimer sentinel;int true_next_timeout_time;ErtsMonotonicTime next_timeout_time;
    };
    

    时间轮维护了一个ERTS_TIW_SIZE大小的定时器指针数组,看头文件定义可以得到ERTS_TIW_SIZE在小内存机器上是 1<<13的大小,大内存机器为1<<16=2^16=2^6*1024=65535大小,这里只看大内存机器;接着有一个pos字段,类型为ErtsMonotonicTime,这是一个long long的别名,顾名思义就是erlang的monotonic时间,简单说就是一个精确到纳秒的单调递增时间;接着有一个at_once空间,有头head、尾tail指针,至于数据结构可能为链表,可能为数组实现的栈或队列等;然后的字段光看名字也无法推断了。进入时间轮操作函数。


time.c的函数只有几个,先罗列简单的:

  1. 创建时间轮 erts_create_timer_wheel

    ErtsTimerWheel *
    erts_create_timer_wheel(ErtsSchedulerData *esdp)
    {ErtsMonotonicTime mtime;int i;ErtsTimerWheel *tiw;tiw = erts_alloc_permanent_cache_aligned(ERTS_ALC_T_TIMER_WHEEL,sizeof(ErtsTimerWheel));for(i = 0; i < ERTS_TIW_SIZE; i++)tiw->w[i] = NULL;mtime = erts_get_monotonic_time(esdp);tiw->pos = ERTS_MONOTONIC_TO_CLKTCKS(mtime);tiw->nto = 0;tiw->at_once.head = NULL;tiw->at_once.tail = NULL;tiw->at_once.nto = 0;tiw->yield_slot = ERTS_TWHEEL_SLOT_INACTIVE;tiw->true_next_timeout_time = 0;tiw->next_timeout_time = mtime + ERTS_MONOTONIC_DAY;tiw->sentinel.next = &tiw->sentinel;tiw->sentinel.prev = &tiw->sentinel;tiw->sentinel.u.func.timeout = NULL;tiw->sentinel.u.func.cancel = NULL;tiw->sentinel.u.func.arg = NULL;return tiw;
    }
    

    看操作是先分配内存,然后初始化w定时器指针数组为NULL,接着获取一次当前的monotonic时间,将它转换为时间轮滴答后赋给pos字段,monotonic时间是精确到纳秒,宏ERTS_MONOTONIC_TO_CLKTCKS将它除以了1000*1000,从这里我们可以知道时间轮每一次走动是1ms,即时间轮的粒度就是1ms了,接下来的操作就是常规的初始化了,到tiw->sentinel.next = $tiw->sentinel语句开始,是将一个sentinel(哨兵)变量变为一个指向自己的循环双向链表。
    结论:
    时间轮的pos字段初始值为创建时间轮时的monotonic时间,但时间轮的精度为ms,故需要将monotonic时间转换为ms(除以1000*1000),pos字段为时间轮的当前指针(想象成钟的分针)。
     

  2. 插入定时器 insert_timer_into_slot

    static ERTS_INLINE void
    insert_timer_into_slot(ErtsTimerWheel *tiw, int slot, ErtsTWheelTimer *p)
    {ERTS_TW_ASSERT(slot >= 0);ERTS_TW_ASSERT(slot < ERTS_TIW_SIZE);p->slot = slot;if (!tiw->w[slot]) {tiw->w[slot] = p;p->next = p;p->prev = p;}else {ErtsTWheelTimer *next, *prev;next = tiw->w[slot];prev = next->prev;p->next = next;p->prev = prev;prev->next = p;next->prev = p;}
    }
    

    先看插入的第1、2两句,断言slot要介于0-ERTS_TIW_SIZE之间:定时器要插到时间轮的槽上,因此必须介于这个范围。然后开始插入,先判断待插入的槽有没有定时器,如果没有,就直接将w[slot]指针指向这个定时器,并且赋值next、prev指针保证循环双向链表特性;如果槽上已经有了别的定时器,那么看else的操作是将待插入的定时器头插到链表中。
    于是看完这个函数,知道了时间轮的主要逻辑如图:

    结论:
    时间轮的槽大小为65535;每个槽是一个定时器指针,指针又维护了一个定时器双向循环链表,跟链式散列表很像;定时器是头插。
     

  3. 去除定时器 remove_timer

    static ERTS_INLINE void
    remove_timer(ErtsTimerWheel *tiw, ErtsTWheelTimer *p)
    {int slot = p->slot;ERTS_TW_ASSERT(slot != ERTS_TWHEEL_SLOT_INACTIVE);if (slot >= 0) {/** Timer in wheel or in circular* list of timers currently beeing* triggered (referred by sentinel).*/ERTS_TW_ASSERT(slot < ERTS_TIW_SIZE);if (p->next == p) {ERTS_TW_ASSERT(tiw->w[slot] == p);tiw->w[slot] = NULL;}else {if (tiw->w[slot] == p)tiw->w[slot] = p->next;p->prev->next = p->next;p->next->prev = p->prev;}}else {/* Timer in "at once" queue... */ERTS_TW_ASSERT(slot == ERTS_TWHEEL_SLOT_AT_ONCE);if (p->prev)p->prev->next = p->next;else {ERTS_TW_ASSERT(tiw->at_once.head == p);tiw->at_once.head = p->next;}if (p->next)p->next->prev = p->prev;else {ERTS_TW_ASSERT(tiw->at_once.tail == p);tiw->at_once.tail = p->prev;}ERTS_TW_ASSERT(tiw->at_once.nto > 0);tiw->at_once.nto--;}p->slot = ERTS_TWHEEL_SLOT_INACTIVE;tiw->nto--;
    }
    

    先看第一个断言slot != ERTS_TWHEEL_SLOT_INACTIVE,这个宏值为-2,前面的函数知道槽数一定是介于0-65535之间,所以猜测如果槽数为-2了,表示定时器未激活。
    往后看,如果槽存在,又分两种情况,一种是这个定时器所处的槽只有它一个定时器,那么需要将槽指针w[slot]置为空,另一种是槽上还有很多定时器,则从循环双向链表中取下一个结点。
    如果槽不存在,且看else的slot为宏值ERTS_TWHEEL_SLOT_AT_ONCE,那么就从at_once队列中去除定时器,并且nto字段减1。
    将定时器的slot字段置为ERTS_TWHEEL_SLOT_INACTIVE,时间轮的nto字段减1。
    结论:
    定时器有三种状态分别为正常、at_once、未激活;at_once队列实则为不循环双向链表;at_once的nto字段记录这个队列上的定时器个数;tiw的nto字段记录所有定时器包括at_once队列上的定时器个数。 

  4. 定时器到时回调 timeout_timer
    回调就很简单,将定时器的slot字段设置为未激活,然后调用回调函数
     

  5. 取消定时器 erts_twheel_cancel_timer
    逻辑与4的到时回调差不多,判断了定时器的slot不能为未激活状态,然后调用remove去除定时器,接着调用定时器的cancel回调函数
     
  6. 创建定时器 erts_twheel_set_timer

    void
    erts_twheel_set_timer(ErtsTimerWheel *tiw,ErtsTWheelTimer *p, ErlTimeoutProc timeout,ErlCancelProc cancel, void *arg,ErtsMonotonicTime timeout_pos)
    {ErtsMonotonicTime timeout_time;ERTS_MSACC_PUSH_AND_SET_STATE_M_X(ERTS_MSACC_STATE_TIMERS);p->u.func.timeout = timeout;p->u.func.cancel = cancel;p->u.func.arg = arg;ERTS_TW_ASSERT(p->slot == ERTS_TWHEEL_SLOT_INACTIVE);if (timeout_pos <= tiw->pos) {tiw->nto++;tiw->at_once.nto++;p->next = NULL;p->prev = tiw->at_once.tail;if (tiw->at_once.tail) {ERTS_TW_ASSERT(tiw->at_once.head);tiw->at_once.tail->next = p;}else {ERTS_TW_ASSERT(!tiw->at_once.head);tiw->at_once.head = p;}tiw->at_once.tail = p;p->timeout_pos = tiw->pos;p->slot = ERTS_TWHEEL_SLOT_AT_ONCE;timeout_time = ERTS_CLKTCKS_TO_MONOTONIC(tiw->pos);}else {int slot;/* calculate slot */slot = (int) (timeout_pos & (ERTS_TIW_SIZE-1));insert_timer_into_slot(tiw, slot, p);tiw->nto++;timeout_time = ERTS_CLKTCKS_TO_MONOTONIC(timeout_pos);p->timeout_pos = timeout_pos;}if (timeout_time < tiw->next_timeout_time) {tiw->true_next_timeout_time = 1;tiw->next_timeout_time = timeout_time;}ERTS_MSACC_POP_STATE_M_X();
    }
    

    逻辑很清楚:传入一个时间轮、定时器、以及定时器要用的相关函数、时间轮上的超时位置(monotonic time / 1000*1000)。
    然后判断超时位置是否小于等于时间轮当前的指针pos,如果是,就把它加入到at_once链表,pos的精度为ms,这个at_once的意思就是加入的定时器差1ms就要到时,而针对这种定时器,再把它插入到槽里做管理和到时是没有意义的,因为马上就到时了。
    正常的定时器则可以插入到槽里了,槽的计算是用到时位置与槽总大小做与运算,举个例子:当前monotonic时间为10,000,000,000,表示开始或者erlang虚拟机开启了10s, 此时创建了一个时间轮,它的pos就该为10,000,然后插入一个5,000,000,000纳秒后到时的定时器,因为时间轮精度为ms,顾折算为(10,000,000,000 + 5,000,000,000)/1000*1000=15,000,即timeout_pos就为15000,那么timeout_pos & ERTS_TIW_SIZE = 15000,那么槽就是15000位置,此时槽还在10000位置,要走5000个滴答才到,同理,如果插入一个距现在65536ms后到时的定时器,则65536超出了65535,但与运算,又变为了0,实现了定时器的循环相加。
    相应nto计数加一,然后判断加入的定时器的到时时间是否小于等于时间轮的下一次到时时间,如果是,就更新时间轮的相应到时值。
    结论:
    定时器如果马上(差1ms)到时的,会加入到at_once队列,否则加入到时间槽里做管理;定时器的到时时间为一个精度为ms的值,然后用这个值跟ERTS_TIW_SIZE做与运算,保证了槽的循环;时间轮还有字段用来表示下一次最近的到时时间,true_next_timeout_time为1表示存在这个时间(即槽上至少存在一个激活的定时器还没到时)。
     

  7. 寻找下一个最近到时时间 find_next_timeout

    static ERTS_INLINE ErtsMonotonicTime
    find_next_timeout(ErtsSchedulerData *esdp,ErtsTimerWheel *tiw,int search_all,ErtsMonotonicTime curr_time,       /* When !search_all */ErtsMonotonicTime max_search_time) /* When !search_all */
    {int start_ix, tiw_pos_ix;ErtsTWheelTimer *p;int true_min_timeout = 0;ErtsMonotonicTime min_timeout, min_timeout_pos, slot_timeout_pos;if (tiw->nto == 0) { /* no timeouts in wheel */if (!search_all)min_timeout_pos = tiw->pos;else {curr_time = erts_get_monotonic_time(esdp);tiw->pos = min_timeout_pos = ERTS_MONOTONIC_TO_CLKTCKS(curr_time);}min_timeout_pos += ERTS_MONOTONIC_TO_CLKTCKS(ERTS_MONOTONIC_DAY);goto found_next;}slot_timeout_pos = min_timeout_pos = tiw->pos;if (search_all)min_timeout_pos += ERTS_MONOTONIC_TO_CLKTCKS(ERTS_MONOTONIC_DAY);elsemin_timeout_pos = ERTS_MONOTONIC_TO_CLKTCKS(curr_time + max_search_time);start_ix = tiw_pos_ix = (int) (tiw->pos & (ERTS_TIW_SIZE-1));do {if (++slot_timeout_pos >= min_timeout_pos)break;p = tiw->w[tiw_pos_ix];if (p) {ErtsTWheelTimer *end = p;do  {ErtsMonotonicTime timeout_pos;timeout_pos = p->timeout_pos;if (min_timeout_pos > timeout_pos) {true_min_timeout = 1;min_timeout_pos = timeout_pos;if (min_timeout_pos <= slot_timeout_pos)goto found_next;}p = p->next;} while (p != end);}tiw_pos_ix++;if (tiw_pos_ix == ERTS_TIW_SIZE)tiw_pos_ix = 0;} while (start_ix != tiw_pos_ix);found_next:min_timeout = ERTS_CLKTCKS_TO_MONOTONIC(min_timeout_pos);tiw->next_timeout_time = min_timeout;tiw->true_next_timeout_time = true_min_timeout;return min_timeout;
    }
    

    函数作用是寻找时间轮所处指针到当前时间curr_time之间最近的一个定时器到时时间。
    函数逻辑分两种情况,一种是时间轮上没有定时器,则判断search_all的值是否要将时间轮的指针拨到当前时间点,然后最小超时时间就为明天的这个时候(因为没有定时器,自然不存在下一个到时的定时器时间);另一种是时间轮上有定时器,则判断search_all的值是,如果为1,寻找的间隔就是一天(24*60*60*1000),否则间隔就是时间轮当前指针到curr_time+max_search_time的距离,然后从时间轮当前指针处开始循环判断每个槽链表,有无定时器的到时时间小于curr_time+max_search_time,如果找了一圈(即走过的距离为ERTS_TIW_SIZE)没找到,就退出,并设置时间轮的下一次到时时间。
    结论:
    时间轮维护了一个下一次到时时间,避免了一段连续的槽上都没有定时器,而在做到时判断时空循环破坏效率。
     

  8. 时间轮嘀嗒 erts_bump_timers

    void
    erts_bump_timers(ErtsTimerWheel *tiw, ErtsMonotonicTime curr_time)
    {int tiw_pos_ix, slots, yielded_slot_restarted, yield_count;ErtsMonotonicTime bump_to, tmp_slots, old_pos;ERTS_MSACC_PUSH_AND_SET_STATE_M_X(ERTS_MSACC_STATE_TIMERS);yield_count = ERTS_TWHEEL_BUMP_YIELD_LIMIT;/** In order to be fair we always continue with work* where we left off when restarting after a yield.*/if (tiw->yield_slot >= 0) {yielded_slot_restarted = 1;tiw_pos_ix = tiw->yield_slot;slots = tiw->yield_slots_left;bump_to = tiw->pos;old_pos = tiw->yield_start_pos;goto restart_yielded_slot;}do {yielded_slot_restarted = 0;bump_to = ERTS_MONOTONIC_TO_CLKTCKS(curr_time);while (1) {ErtsTWheelTimer *p;old_pos = tiw->pos;if (tiw->nto == 0) {empty_wheel:ERTS_DBG_CHK_SAFE_TO_SKIP_TO(tiw, bump_to);tiw->true_next_timeout_time = 0;tiw->next_timeout_time = curr_time + ERTS_MONOTONIC_DAY;tiw->pos = bump_to;tiw->yield_slot = ERTS_TWHEEL_SLOT_INACTIVE;ERTS_MSACC_POP_STATE_M_X();return;}p = tiw->at_once.head;while (p) {if (--yield_count <= 0) {ERTS_TW_ASSERT(tiw->nto > 0);ERTS_TW_ASSERT(tiw->at_once.nto > 0);tiw->yield_slot = ERTS_TWHEEL_SLOT_AT_ONCE;tiw->true_next_timeout_time = 1;tiw->next_timeout_time = ERTS_CLKTCKS_TO_MONOTONIC(old_pos);ERTS_MSACC_POP_STATE_M_X();return;}ERTS_TW_ASSERT(tiw->nto > 0);ERTS_TW_ASSERT(tiw->at_once.nto > 0);tiw->nto--;tiw->at_once.nto--;tiw->at_once.head = p->next;if (p->next)p->next->prev = NULL;elsetiw->at_once.tail = NULL;timeout_timer(p);p = tiw->at_once.head;}if (tiw->pos >= bump_to) {ERTS_MSACC_POP_STATE_M_X();break;}if (tiw->nto == 0)goto empty_wheel;if (tiw->true_next_timeout_time) {ErtsMonotonicTime skip_until_pos;/** No need inspecting slots where we know no timeouts* to trigger should reside.*/skip_until_pos = ERTS_MONOTONIC_TO_CLKTCKS(tiw->next_timeout_time);if (skip_until_pos > bump_to)skip_until_pos = bump_to;skip_until_pos--;if (skip_until_pos > tiw->pos) {ERTS_DBG_CHK_SAFE_TO_SKIP_TO(tiw, skip_until_pos);tiw->pos = skip_until_pos;}}tiw_pos_ix = (int) ((tiw->pos+1) & (ERTS_TIW_SIZE-1));tmp_slots = (bump_to - tiw->pos);if (tmp_slots < (ErtsMonotonicTime) ERTS_TIW_SIZE)slots = (int) tmp_slots;elseslots = ERTS_TIW_SIZE;tiw->pos = bump_to;while (slots > 0) {p = tiw->w[tiw_pos_ix];if (p) {if (p->next == p) {ERTS_TW_ASSERT(tiw->sentinel.next == &tiw->sentinel);ERTS_TW_ASSERT(tiw->sentinel.prev == &tiw->sentinel);} else {tiw->sentinel.next = p->next;tiw->sentinel.prev = p->prev;tiw->sentinel.next->prev = &tiw->sentinel;tiw->sentinel.prev->next = &tiw->sentinel;}tiw->w[tiw_pos_ix] = NULL;while (1) {if (p->timeout_pos > bump_to) {/* Very unusual case... */++yield_count;insert_timer_into_slot(tiw, tiw_pos_ix, p);} else {/* Normal case... */timeout_timer(p);tiw->nto--;}restart_yielded_slot:p = tiw->sentinel.next;if (p == &tiw->sentinel) {ERTS_TW_ASSERT(tiw->sentinel.prev == &tiw->sentinel);break;}if (--yield_count <= 0) {tiw->true_next_timeout_time = 1;tiw->next_timeout_time = ERTS_CLKTCKS_TO_MONOTONIC(old_pos);tiw->yield_slot = tiw_pos_ix;tiw->yield_slots_left = slots;tiw->yield_start_pos = old_pos;ERTS_MSACC_POP_STATE_M_X();return; /* Yield! */}tiw->sentinel.next = p->next;p->next->prev = &tiw->sentinel;}}tiw_pos_ix++;if (tiw_pos_ix == ERTS_TIW_SIZE)tiw_pos_ix = 0;slots--;}}} while (yielded_slot_restarted);tiw->yield_slot = ERTS_TWHEEL_SLOT_INACTIVE;tiw->true_next_timeout_time = 0;tiw->next_timeout_time = curr_time + ERTS_MONOTONIC_DAY;/* Search at most two seconds ahead... */(void) find_next_timeout(NULL, tiw, 0, curr_time, ERTS_SEC_TO_MONOTONIC(2));ERTS_MSACC_POP_STATE_M_X();
    }
    

    这是最重要的一个函数,erlang虚拟机启动后,有一个线程做周期性调用,来检测有无定时器到时。
    函数接收一个curr_time形参,将时间轮上小于等于此时间的定时器都视为到时,所以估计是1ms调用一次。
    函数定义了yield_count=100,如果at_once或者某个槽上大于100个定时器,就丢弃多的。
    这个函数写得很恶心,又是do while{},又是while(1),又是while,但剥离开,真正的逻辑就一段:循环将at_once链表的定时器全部到时,则at_once链表清空了;开始判断时间槽,先利用下一个最近的到时时间next_timeout_time跳过一段槽,然后开始遍历从时间轮的当前指针pos到curr_time之间的间隔槽,再遍历每个槽上的链表,对每个结点判断是否大于等于curr_time,即判断是否到时,如果到时就可以去掉定时器,并执行回调任务。
    以上步骤就做完了到时任务,调用一下find_next_timeout寻找一次最近到时时间。


在看erts_bump_timers函数时候看到一段goto的代码形如:

goto test_label:int a = 0;test_label:a = 1;

当时很诧异,a不是没定义吗?激动得不行,摩拳擦掌准备提bug,抱着谨慎的态度还是查了一下,这种用法是可以的,真是菜得不行 …… 自己猜想一下可能是编译期已经将a加入了符号表,goto只影响运行时。

erlang底层c定时器设计-Erlang源码学习二相关推荐

  1. ROS源码学习 二、线程池

    2021SC@SDUSC 目录 1.写在前面 2.ROS线程池概述 3.ROS线程池模型 4.ROS线程池源码详解 5.总结 1.写在前面 ROS作为一个操作系统,其职责是协调具有不同功能的node之 ...

  2. 使用base标签后图片无法加载_Spring 源码学习(二)-默认标签解析

    `Spring` 解析默认标签~ 从上一篇笔记可以看出,在容器注册 bean 信息的时候,做了很多解析操作,而 xml 文件中包含了很多标签.属性,例如 bean . import 标签, meta ...

  3. Golang源码学习(二)----Go源码学习基础

    ### 本文源码版本为 GO 1.17.8 Windows/amd64: ### 可能参与对比的版本:GO 1.16.2 Linux/amd64一.Golang的编译器究竟是如何工作的? (学习源码有 ...

  4. java集合类程序代码_java集合类源码学习二

    我们查看Collection接口的hierarchy时候,可以看到AbstractCollection这样一个抽象类,它实现了Collection接口的部分方法,Collection集合系列的各个集合 ...

  5. SocketServer源码学习(二)

    SocketServer 中非常重要的两个基类就是:BaseServer 和 BaseRequestHandler 在SocketServer 中也提供了对TCP以及UDP的高级封装,这次我们主要通过 ...

  6. Thrift源码学习二——Server层

    Thrift 提供了如图五种模式:TSimpleServer.TNonblockingServer.THsHaServer.TThreadPoolServer.TThreadSelectorServe ...

  7. yara 源码学习(二) 规则编译部分

    yara规则的详细信息请参考: https://yara.readthedocs.io/en/stable/writingrules.html 根据官方文档,yara规则长这个样子: [1]:yara ...

  8. Box2d源码学习二内存管理之SOA的实现

    本系列博客是由扭曲45原创,欢迎转载,转载时注明出处,http://blog.csdn.net/cg0206/article/details/8258166 SOA,全称small object al ...

  9. Mybatis源码学习二(一级缓存)

    一级缓存流程 一级缓存有效的因素 一级缓存有效测试 public class User {private Integer id;private String name;public Integer g ...

最新文章

  1. 2008 r2 sn sqlserver_sqlserver 下载地址(SQL Server 2008 R2 中英文 开发版/企业版/标准版 下载)...
  2. 俄罗斯研发替身机器人,危险工作无需真人
  3. leetcode mysql 排名_(LeetCode:数据库)分数排名
  4. swagger默认访问路径_swagger 如何配置项目访问路径
  5. [读书笔记]Ajax的通信方式 (一)
  6. oracle 监听报错,Oracle EBS系统数据库监听报错TNS-12547、TNS-12560和TNS-00517解决方法...
  7. 浏览器的不兼容,归纳几点html编码要素
  8. 历年二级c语言考试真题及答案,历年全国计算机等级考试二级C语言笔试选择真题及答案...
  9. 机房管理--如何应对学生关闭极域电子教室
  10. 分析137份大数据简历-统计技术名词词频
  11. javaScript导出excel表格,数据量过大导出失败问题
  12. 搜索引擎技术/动态资源
  13. 影响利率风险结构的因素_利率风险结构是什么意思 影响利率的因素
  14. golang:kratos生成proto以及API编写
  15. matlab差分阶跃响应,matlab在DSP中的应用(四)---离散系统的冲激响应和阶跃响应...
  16. 计算机技能大赛奖品,第一届全国技能大赛都有哪些奖励政策?
  17. UEFI应用与编程--SMM(一)
  18. jqurey的选择器使用
  19. (Python)LeetCode1386:安排电影院座位
  20. web前端学习笔记之JavaScript

热门文章

  1. 沧小海读《图解TCP/IP》笔记——第一章 网络基础知识
  2. 【蓝牙开发】低功耗蓝牙ATT/GATT/Profile/Service/Characteristic规格解读
  3. android 紫外线传感器,Arduino光线传感器-UV Sensor V1.0-ML8511紫外线传感器
  4. 计算机主板复位电路的组成,电脑主板复位电路工作原理
  5. 手把手教你搭建免流服务器(只限于联通)
  6. 小程序获取用户头像、昵称
  7. django-模板语言-verbatim标签-不使用DTL的解析
  8. Web前端开发有哪些岗位职责 要掌握什么技能
  9. 【自动驾驶】Frenet坐标系与Cartesian坐标系(一)
  10. 治疗口腔溃疡的穴位按摩方法