--- Timer wheel implementation -- -- Efficient timer for timeout related timers: fast insertion, deletion, and -- execution (all as O(1) implemented), but with lesser precision. -- -- This module will not provide the timer/runloop itself. Use your own runloop -- and call `wheel:step` to check and execute timers. -- -- Implementation: -- Consider a stack of rings, a timer beyond the current ring size is in the -- next ring (or beyond). Precision is based on a slot with a specific size. -- -- The code explicitly avoids using `pairs`, `ipairs` and `next` to ensure JIT -- compilation when using LuaJIT local default_now -- return time in seconds if ngx then default_now = ngx.now else local ok, socket = pcall(require, "socket") if ok then default_now = socket.gettime else default_now = nil -- we don't have a default end end local ok, new_tab = pcall(require, "table.new") if not ok then new_tab = function(narr, nrec) return {} end end local xpcall = require("coxpcall").xpcall local default_err_handler = function(err) io.stderr:write(debug.traceback("TimerWheel callback failed with: " .. tostring(err)).."\n") end local math_floor = math.floor local math_huge = math.huge local EMPTY = {} local _M = {} --- Creates a new timer wheel. -- @tparam table opts the options table -- @tparam[opt=0.050] number opts.precision the precision of the timer wheel in seconds (slot size), -- @tparam[opt] int opts.ringsize number of slots in each ring, defaults to 72000 (1 -- hour span, with `precision == 0.050`) -- @tparam[opt] function opts.now a function returning the curent time in seconds. Defaults -- to `ngx.now` or `luasocket.gettime` if available. -- @tparam[opt] function opts.err_handler a function to use as error handler in an `xpcall` when -- executing the callback. The default will write the stacktrace to `stderr`. -- @treturn wheel the timerwheel object function _M.new(opts) assert(opts ~= _M, "new should not be called with colon ':' notation") opts = opts or EMPTY assert(type(opts) == "table", "expected options to be a table") local precision = opts.precision or 0.050 -- in seconds, 50ms by default local ringsize = opts.ringsize or 72000 -- #slots per ring, default 1 hour = 60 * 60 / 0.050 local now = opts.now or default_now -- function to get time in seconds local err_handler = opts.err_handler or default_err_handler opts = nil -- luacheck: ignore assert(type(precision) == "number" and precision > 0, "expected 'precision' to be number > 0") assert(type(ringsize) == "number" and ringsize > 0 and math_floor(ringsize) == ringsize, "expected 'ringsize' to be an integer number > 0") assert(type(now) == "function", "expected 'now' to be a function, got: " .. type(now)) assert(type(err_handler) == "function", "expected 'err_handler' to be a function, got: " .. type(err_handler)) local start = now() local position = 1 -- position next up in first ring of timer wheel local id_count = 0 -- counter to generate unique ids (all negative) local id_list = {} -- reverse lookup table to find timers by id local rings = {} -- list of rings, index 1 is the current ring local rings_n = 0 -- the number of the last ring in the rings list local count = 0 -- how many timers do we have local wheel = {} -- the returned wheel object -- because we assume hefty setting and cancelling, we're reusing tables -- to prevent excessive GC. local tables = {} -- list of tables to be reused local tables_n = 0 -- number of tables in the list --- Checks and executes timers. -- Call this function (at least) every `precision` seconds. -- @return `true` function wheel:step() local new_position = math_floor((now() - start) / precision) + 1 local ring = rings[1] or EMPTY while position < new_position do -- get the expired slot, and remove it from the ring local slot = ring[position] ring[position] = nil -- forward pointers position = position + 1 if position > ringsize then -- current ring is done, remove it and forward pointers for i = 1, rings_n do -- manual loop, since table.remove won't deal with holes -- FIXME: If there are a large number of rings, then this loop becomes -- a "stop the world" event. rings[i] = rings[i + 1] end rings_n = rings_n - 1 ring = rings[1] or EMPTY start = start + ringsize * precision position = 1 new_position = new_position - ringsize end -- only deal with slot after forwarding pointers, to make sure that -- any cb inserting another timer, does not end up in the slot being -- handled if slot then -- deal with the slot local ids = slot.ids local args = slot.arg for i = 1, slot.n do local id = slot[i]; slot[i] = nil; slot[id] = nil local cb = ids[id]; ids[id] = nil local arg = args[id]; args[id] = nil id_list[id] = nil count = count - 1 xpcall(cb, err_handler, arg) end slot.n = 0 -- delete the slot tables_n = tables_n + 1 tables[tables_n] = slot end end return true end --- Gets the number of timers. -- @treturn int number of timers function wheel:count() return count end --- Sets a timer. -- @tparam number expire_in in how many seconds should the timer expire -- @tparam function cb callback function to execute upon expiring (NOTE: the -- callback will run within an `xpcall`) -- @param arg parameter to be passed to `cb` when executing -- @treturn int the id of the newly set timer -- @usage -- local cb = function(arg) -- print("timer executed with: ", arg) --> "timer executed with: hello world" -- end -- local id = wheel:set(5, cb, "hello world") -- -- -- do stuff here, while regularly calling `wheel:step()` -- -- wheel:cancel(id) -- cancel the timer again function wheel:set(expire_in, cb, arg) local time_expire = now() + expire_in local pos = math_floor((time_expire - start) / precision) + 1 if pos < position then -- we cannot set it in the past pos = position end local ring_idx = math_floor((pos - 1) / ringsize) + 1 local slot_idx = pos - (ring_idx - 1) * ringsize -- fetch actual ring table local ring = rings[ring_idx] if not ring then ring = new_tab(ringsize, 0) rings[ring_idx] = ring if ring_idx > rings_n then rings_n = ring_idx end end -- fetch actual slot local slot = ring[slot_idx] if not slot then if tables_n == 0 then slot = { n = 0, ids = {}, arg = {} } else slot = tables[tables_n] tables_n = tables_n - 1 end ring[slot_idx] = slot end -- get new id local id = id_count - 1 -- use negative idx to not interfere with array part id_count = id -- store timer -- if we do not do this check, it will go unnoticed and lead to very -- hard to find bugs (`count` will go out of sync) slot.ids[id] = cb or error("the callback parameter is required", 2) slot.arg[id] = arg local idx = slot.n + 1 slot.n = idx slot[idx] = id slot[id] = idx id_list[id] = slot count = count + 1 return id end --- Cancels a timer. -- @tparam int id the timer id to cancel -- @treturn boolean `true` if cancelled, `false` if not found function wheel:cancel(id) local slot = id_list[id] if slot then local idx = slot[id] slot[id] = nil slot.ids[id] = nil slot.arg[id] = nil local n = slot.n if idx ~= n then local moved_id = slot[n] slot[idx] = moved_id slot[moved_id] = idx end slot[n] = nil slot.n = n - 1 id_list[id] = nil count = count - 1 return true end return false end --- Looks up the next expiring timer. -- Note: traverses the wheel, O(n) operation! -- @tparam[opt] number max_ahead maximum time (in seconds) -- to look ahead -- @treturn number number of seconds until next timer expires (can be negative), or -- 'nil' if there is no timer from now to `max_ahead` -- @usage -- local t = wheel:peek(10) -- if t then -- print("next timer expires in ", t," seconds") -- else -- print("no timer scheduled for the next 10 seconds") -- end function wheel:peek(max_ahead) if count == 0 then return nil end local time_now = now() -- convert max_ahead from seconds to positions if max_ahead then max_ahead = math_floor((time_now + max_ahead - start) / precision) else max_ahead = math_huge end local position_idx = position local ring_idx = 1 local ring = rings[ring_idx] or EMPTY -- TODO: if EMPTY then we can skip it? local ahead_count = 0 while ahead_count < max_ahead do local slot = ring[position_idx] if slot then if slot[1] then -- we have a timer return ((ring_idx - 1) * ringsize + position_idx) * precision + start - time_now end end -- there is nothing in this position position_idx = position_idx + 1 ahead_count = ahead_count + 1 if position_idx > ringsize then position_idx = 1 ring_idx = ring_idx + 1 ring = rings[ring_idx] or EMPTY end end -- we hit max_ahead, without finding a timer return nil end if _G._TEST then -- export test variables only when testing -- wheel._rings = rings wheel._tables = tables end return wheel end return _M