sokol-luajit/lua/timerwheel/init.lua

308 lines
9.5 KiB
Lua

--- 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