# frozen_string_literal: true # (The MIT License) # # Copyright (c) 2018 Yegor Bugayenko # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the 'Software'), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. require 'fileutils' require 'time' # Futex (file mutex) is a fine-grained mutex that uses a file, not an entire # thread, like Mutex does. Use it like this: # # require 'futex' # Futex.new('/tmp/my-file.txt').open |f| # IO.write(f, 'Hello, world!') # end # # The file /tmp/my-file.txt.lock will be created and # used as an entrance lock. If the file is already locked by another thread # or another process, exception Futex::CantLock will be raised. # # If you are not planning to write to the file, to speed things up, you may # want to get a non-exclusive access to it, by providing false to # the method open(): # # require 'futex' # Futex.new('/tmp/my-file.txt').open(false) |f| # IO.read(f) # end # # For more information read # {README}[https://github.com/yegor256/futex/blob/master/README.md] file. # # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2018 Yegor Bugayenko # License:: MIT class Futex # Exception that is raised when we can't lock because of some other # process that is holding the lock now. There is an encapsulated # start attribute of type Time, which points to the time # when we started to try to acquire lock. class CantLock < StandardError attr_reader :start def initialize(msg, start) @start = start super(msg) end end # Creates a new instance of the class. def initialize(path, log: STDOUT, timeout: 16, sleep: 0.005, lock: path + '.lock', logging: false) @path = path @log = log @logging = logging @timeout = timeout @sleep = sleep @lock = lock end # Open the file. By default the file will be locked for exclusive access, # which means that absolutely no other process will be able to do the same. # This type of access (exclusive) is supposed to be used when you are # making changes to the file. However, very often you may need just to # read it and it's OK to let many processes do the reading at the same time, # provided none of them do the writing. In that case you should call this # method open() with false first argument, which will mean # "shared" access. Many threads and processes may have shared access to the # same lock file, but they all will stop and wait if one of them will require # an "exclusive" access. This mechanism is inherited from POSIX, read about # it here. def open(exclusive = true) FileUtils.mkdir_p(File.dirname(@lock)) step = (1 / @sleep).to_i start = Time.now prefix = exclusive ? '' : 'non-' b = badge(exclusive) Thread.current.thread_variable_set(:futex_lock, @lock) Thread.current.thread_variable_set(:futex_badge, b) File.open(@lock, File::CREAT | File::RDWR) do |f| cycle = 0 loop do if f.flock((exclusive ? File::LOCK_EX : File::LOCK_SH) | File::LOCK_NB) Thread.current.thread_variable_set(:futex_cycle, nil) Thread.current.thread_variable_set(:futex_time, nil) break end sleep(@sleep) cycle += 1 Thread.current.thread_variable_set(:futex_cycle, cycle) Thread.current.thread_variable_set(:futex_time, Time.now - start) if Time.now - start > @timeout raise CantLock.new("#{b} can't get #{prefix}exclusive access \ to the file #{@path} because of the lock at #{@lock}, after #{age(start)} \ of waiting: #{IO.read(@lock)} (modified #{age(File.mtime(@lock))} ago)", start) end next unless (cycle % step).zero? && Time.now - start > @timeout / 2 debug("#{b} still waiting for #{prefix}exclusive \ access to #{@path}, #{age(start)} already: #{IO.read(@lock)} \ (modified #{age(File.mtime(@lock))} ago)") end debug("Locked by #{b} in #{age(start)}, #{prefix}exclusive: \ #{@path} (attempt no.#{cycle})") File.write(@lock, b) acq = Time.now res = yield(@path) debug("Unlocked by #{b} in #{age(acq)}, #{prefix}exclusive: #{@path}") res end ensure Thread.current.thread_variable_set(:futex_cycle, nil) Thread.current.thread_variable_set(:futex_time, nil) Thread.current.thread_variable_set(:futex_lock, nil) Thread.current.thread_variable_set(:futex_badge, nil) end private def badge(exclusive) tname = Thread.current.name tname = 'nil' if tname.nil? "##{Process.pid}-#{exclusive ? 'ex' : 'sh'}/#{tname}[#{caller(2..2).first}]" end def age(time) sec = Time.now - time return "#{(sec * 1_000_000).round}μs" if sec < 0.001 return "#{(sec * 1000).round}ms" if sec < 1 return "#{sec.round(2)}s" if sec < 60 return "#{(sec / 60).round}m" if sec < 60 * 60 "#{(sec / 3600).round}h" end def debug(msg) return unless @logging if @log.respond_to?(:debug) @log.debug(msg) elsif @log.respond_to?(:puts) @log.puts(msg) end end end