thread_safe 0.3.4 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.travis.yml +30 -11
- data/.yardopts +13 -0
- data/Gemfile +14 -1
- data/README.md +1 -1
- data/Rakefile +13 -0
- data/ext/org/jruby/ext/thread_safe/JRubyCacheBackendLibrary.java +2 -2
- data/lib/thread_safe/atomic_reference_cache_backend.rb +155 -169
- data/lib/thread_safe/mri_cache_backend.rb +14 -7
- data/lib/thread_safe/non_concurrent_cache_backend.rb +4 -2
- data/lib/thread_safe/synchronized_cache_backend.rb +2 -1
- data/lib/thread_safe/synchronized_delegator.rb +3 -3
- data/lib/thread_safe/util/adder.rb +5 -2
- data/lib/thread_safe/util/cheap_lockable.rb +2 -1
- data/lib/thread_safe/util/striped64.rb +52 -56
- data/lib/thread_safe/util/volatile.rb +3 -1
- data/lib/thread_safe/util/xor_shift_random.rb +4 -2
- data/lib/thread_safe/version.rb +1 -1
- data/tasks/update_doc.rake +45 -0
- data/test/test_array.rb +1 -1
- data/test/test_cache.rb +1 -1
- data/test/test_hash.rb +1 -1
- data/test/test_helper.rb +30 -9
- data/thread_safe.gemspec +2 -2
- data/yard-template/default/fulldoc/html/css/common.css +125 -0
- data/yard-template/default/layout/html/footer.erb +16 -0
- metadata +20 -21
@@ -1,14 +1,21 @@
|
|
1
1
|
module ThreadSafe
|
2
2
|
class MriCacheBackend < NonConcurrentCacheBackend
|
3
|
-
# We can get away with a single global write lock (instead of a per-instance
|
3
|
+
# We can get away with a single global write lock (instead of a per-instance
|
4
|
+
# one) because of the GVL/green threads.
|
4
5
|
#
|
5
|
-
# The previous implementation used `Thread.critical` on 1.8 MRI to implement
|
6
|
-
#
|
7
|
-
#
|
8
|
-
# `
|
6
|
+
# The previous implementation used `Thread.critical` on 1.8 MRI to implement
|
7
|
+
# the 4 composed atomic operations (`put_if_absent`, `replace_pair`,
|
8
|
+
# `replace_if_exists`, `delete_pair`) this however doesn't work for
|
9
|
+
# `compute_if_absent` because on 1.8 the Mutex class is itself implemented
|
10
|
+
# via `Thread.critical` and a call to `Mutex#lock` does not restore the
|
11
|
+
# previous `Thread.critical` value (thus any synchronisation clears the
|
12
|
+
# `Thread.critical` flag and we loose control). This poses a problem as the
|
13
|
+
# provided block might use synchronisation on its own.
|
9
14
|
#
|
10
|
-
# NOTE: a neat idea of writing a c-ext to manually perform atomic
|
11
|
-
#
|
15
|
+
# NOTE: a neat idea of writing a c-ext to manually perform atomic
|
16
|
+
# put_if_absent, while relying on Ruby not releasing a GVL while calling a
|
17
|
+
# c-ext will not work because of the potentially Ruby implemented `#hash`
|
18
|
+
# and `#eql?` key methods.
|
12
19
|
WRITE_LOCK = Mutex.new
|
13
20
|
|
14
21
|
def []=(key, value)
|
@@ -1,7 +1,9 @@
|
|
1
1
|
module ThreadSafe
|
2
2
|
class NonConcurrentCacheBackend
|
3
|
-
# WARNING: all public methods of the class must operate on the @backend
|
4
|
-
#
|
3
|
+
# WARNING: all public methods of the class must operate on the @backend
|
4
|
+
# directly without calling each other. This is important because of the
|
5
|
+
# SynchronizedCacheBackend which uses a non-reentrant mutex for perfomance
|
6
|
+
# reasons.
|
5
7
|
def initialize(options = nil)
|
6
8
|
@backend = {}
|
7
9
|
end
|
@@ -2,7 +2,8 @@ module ThreadSafe
|
|
2
2
|
class SynchronizedCacheBackend < NonConcurrentCacheBackend
|
3
3
|
require 'mutex_m'
|
4
4
|
include Mutex_m
|
5
|
-
# WARNING: Mutex_m is a non-reentrant lock, so the synchronized methods are
|
5
|
+
# WARNING: Mutex_m is a non-reentrant lock, so the synchronized methods are
|
6
|
+
# not allowed to call each other.
|
6
7
|
|
7
8
|
def [](key)
|
8
9
|
synchronize { super }
|
@@ -9,9 +9,9 @@ require 'monitor'
|
|
9
9
|
# array = SynchronizedDelegator.new([]) # thread-safe
|
10
10
|
#
|
11
11
|
# A simple `Monitor` provides a very coarse-grained way to synchronize a given
|
12
|
-
# object, in that it will cause synchronization for methods that have no
|
13
|
-
#
|
14
|
-
#
|
12
|
+
# object, in that it will cause synchronization for methods that have no need
|
13
|
+
# for it, but this is a trivial way to get thread-safety where none may exist
|
14
|
+
# currently on some implementations.
|
15
15
|
#
|
16
16
|
# This class is currently being considered for inclusion into stdlib, via
|
17
17
|
# https://bugs.ruby-lang.org/issues/8556
|
@@ -1,7 +1,10 @@
|
|
1
1
|
module ThreadSafe
|
2
2
|
module Util
|
3
|
-
# A Ruby port of the Doug Lea's jsr166e.LondAdder class version 1.8
|
4
|
-
#
|
3
|
+
# A Ruby port of the Doug Lea's jsr166e.LondAdder class version 1.8
|
4
|
+
# available in public domain.
|
5
|
+
#
|
6
|
+
# Original source code available here:
|
7
|
+
# http://gee.cs.oswego.edu/cgi-bin/viewcvs.cgi/jsr166/src/jsr166e/LongAdder.java?revision=1.8
|
5
8
|
#
|
6
9
|
# One or more variables that together maintain an initially zero
|
7
10
|
# sum. When updates (method +add+) are contended across threads,
|
@@ -1,6 +1,7 @@
|
|
1
1
|
module ThreadSafe
|
2
2
|
module Util
|
3
|
-
# Provides a cheapest possible (mainly in terms of memory usage) +Mutex+
|
3
|
+
# Provides a cheapest possible (mainly in terms of memory usage) +Mutex+
|
4
|
+
# with the +ConditionVariable+ bundled in.
|
4
5
|
#
|
5
6
|
# Usage:
|
6
7
|
# class A
|
@@ -1,69 +1,65 @@
|
|
1
1
|
module ThreadSafe
|
2
2
|
module Util
|
3
|
-
# A Ruby port of the Doug Lea's jsr166e.Striped64 class version 1.6
|
4
|
-
#
|
3
|
+
# A Ruby port of the Doug Lea's jsr166e.Striped64 class version 1.6
|
4
|
+
# available in public domain.
|
5
5
|
#
|
6
|
-
#
|
6
|
+
# Original source code available here:
|
7
|
+
# http://gee.cs.oswego.edu/cgi-bin/viewcvs.cgi/jsr166/src/jsr166e/Striped64.java?revision=1.6
|
7
8
|
#
|
8
|
-
#
|
9
|
-
#
|
10
|
-
# is a power of two. Indexing uses masked per-thread hash codes.
|
11
|
-
# Nearly all methods on this class are private, accessed directly
|
12
|
-
# by subclasses.
|
9
|
+
# Class holding common representation and mechanics for classes supporting
|
10
|
+
# dynamic striping on 64bit values.
|
13
11
|
#
|
14
|
-
#
|
15
|
-
#
|
16
|
-
#
|
17
|
-
#
|
18
|
-
#
|
19
|
-
#
|
20
|
-
# cache
|
12
|
+
# This class maintains a lazily-initialized table of atomically updated
|
13
|
+
# variables, plus an extra +base+ field. The table size is a power of two.
|
14
|
+
# Indexing uses masked per-thread hash codes. Nearly all methods on this
|
15
|
+
# class are private, accessed directly by subclasses.
|
16
|
+
#
|
17
|
+
# Table entries are of class +Cell+; a variant of AtomicLong padded to
|
18
|
+
# reduce cache contention on most processors. Padding is overkill for most
|
19
|
+
# Atomics because they are usually irregularly scattered in memory and thus
|
20
|
+
# don't interfere much with each other. But Atomic objects residing in
|
21
|
+
# arrays will tend to be placed adjacent to each other, and so will most
|
22
|
+
# often share cache lines (with a huge negative performance impact) without
|
21
23
|
# this precaution.
|
22
24
|
#
|
23
|
-
# In part because +Cell+s are relatively large, we avoid creating
|
24
|
-
#
|
25
|
-
#
|
26
|
-
#
|
27
|
-
#
|
28
|
-
#
|
29
|
-
# number of CPUS. Table slots remain empty (+nil+) until they are
|
25
|
+
# In part because +Cell+s are relatively large, we avoid creating them until
|
26
|
+
# they are needed. When there is no contention, all updates are made to the
|
27
|
+
# +base+ field. Upon first contention (a failed CAS on +base+ update), the
|
28
|
+
# table is initialized to size 2. The table size is doubled upon further
|
29
|
+
# contention until reaching the nearest power of two greater than or equal
|
30
|
+
# to the number of CPUS. Table slots remain empty (+nil+) until they are
|
30
31
|
# needed.
|
31
32
|
#
|
32
|
-
# A single spinlock (+busy+) is used for initializing and
|
33
|
-
#
|
34
|
-
#
|
35
|
-
#
|
36
|
-
#
|
37
|
-
# which is still better than alternatives.
|
33
|
+
# A single spinlock (+busy+) is used for initializing and resizing the
|
34
|
+
# table, as well as populating slots with new +Cell+s. There is no need for
|
35
|
+
# a blocking lock: When the lock is not available, threads try other slots
|
36
|
+
# (or the base). During these retries, there is increased contention and
|
37
|
+
# reduced locality, which is still better than alternatives.
|
38
38
|
#
|
39
|
-
# Per-thread hash codes are initialized to random values.
|
40
|
-
#
|
41
|
-
#
|
42
|
-
#
|
43
|
-
# the
|
44
|
-
#
|
45
|
-
#
|
46
|
-
#
|
47
|
-
# using a secondary hash (XorShift) to try to find a
|
48
|
-
# free slot.
|
39
|
+
# Per-thread hash codes are initialized to random values. Contention and/or
|
40
|
+
# table collisions are indicated by failed CASes when performing an update
|
41
|
+
# operation (see method +retry_update+). Upon a collision, if the table size
|
42
|
+
# is less than the capacity, it is doubled in size unless some other thread
|
43
|
+
# holds the lock. If a hashed slot is empty, and lock is available, a new
|
44
|
+
# +Cell+ is created. Otherwise, if the slot exists, a CAS is tried. Retries
|
45
|
+
# proceed by "double hashing", using a secondary hash (XorShift) to try to
|
46
|
+
# find a free slot.
|
49
47
|
#
|
50
|
-
# The table size is capped because, when there are more threads
|
51
|
-
#
|
52
|
-
#
|
53
|
-
#
|
54
|
-
#
|
55
|
-
#
|
56
|
-
#
|
57
|
-
#
|
58
|
-
#
|
59
|
-
# observed contention rates are typically low in these cases.
|
48
|
+
# The table size is capped because, when there are more threads than CPUs,
|
49
|
+
# supposing that each thread were bound to a CPU, there would exist a
|
50
|
+
# perfect hash function mapping threads to slots that eliminates collisions.
|
51
|
+
# When we reach capacity, we search for this mapping by randomly varying the
|
52
|
+
# hash codes of colliding threads. Because search is random, and collisions
|
53
|
+
# only become known via CAS failures, convergence can be slow, and because
|
54
|
+
# threads are typically not bound to CPUS forever, may not occur at all.
|
55
|
+
# However, despite these limitations, observed contention rates are
|
56
|
+
# typically low in these cases.
|
60
57
|
#
|
61
|
-
# It is possible for a +Cell+ to become unused when threads that
|
62
|
-
#
|
63
|
-
#
|
64
|
-
#
|
65
|
-
#
|
66
|
-
# contention levels will recur, so the cells will eventually be
|
58
|
+
# It is possible for a +Cell+ to become unused when threads that once hashed
|
59
|
+
# to it terminate, as well as in the case where doubling the table causes no
|
60
|
+
# thread to hash to it under expanded mask. We do not try to detect or
|
61
|
+
# remove such cells, under the assumption that for long-running instances,
|
62
|
+
# observed contention levels will recur, so the cells will eventually be
|
67
63
|
# needed again; and for short-lived ones, it does not matter.
|
68
64
|
class Striped64
|
69
65
|
# Padded variant of AtomicLong supporting only raw accesses plus CAS.
|
@@ -85,8 +81,8 @@ module ThreadSafe
|
|
85
81
|
|
86
82
|
extend Volatile
|
87
83
|
attr_volatile :cells, # Table of cells. When non-null, size is a power of 2.
|
88
|
-
|
89
|
-
|
84
|
+
:base, # Base value, used mainly when there is no contention, but also as a fallback during table initialization races. Updated via CAS.
|
85
|
+
:busy # Spinlock (locked via CAS) used when resizing and/or creating Cells.
|
90
86
|
|
91
87
|
alias_method :busy?, :busy
|
92
88
|
|
@@ -1,7 +1,9 @@
|
|
1
1
|
module ThreadSafe
|
2
2
|
module Util
|
3
3
|
module Volatile
|
4
|
-
# Provides +volatile+ (in the JVM's sense) attribute accessors implemented
|
4
|
+
# Provides +volatile+ (in the JVM's sense) attribute accessors implemented
|
5
|
+
# atop of the +AtomicReference+s.
|
6
|
+
#
|
5
7
|
# Usage:
|
6
8
|
# class Foo
|
7
9
|
# extend ThreadSafe::Util::Volatile
|
@@ -1,7 +1,9 @@
|
|
1
1
|
module ThreadSafe
|
2
2
|
module Util
|
3
|
-
# A xorshift random number (positive +Fixnum+s) generator, provides
|
4
|
-
#
|
3
|
+
# A xorshift random number (positive +Fixnum+s) generator, provides
|
4
|
+
# reasonably cheap way to generate thread local random numbers without
|
5
|
+
# contending for the global +Kernel.rand+.
|
6
|
+
#
|
5
7
|
# Usage:
|
6
8
|
# x = XorShiftRandom.get # uses Kernel.rand to generate an initial seed
|
7
9
|
# while true
|
data/lib/thread_safe/version.rb
CHANGED
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'yard'
|
2
|
+
YARD::Rake::YardocTask.new
|
3
|
+
|
4
|
+
root = File.expand_path File.join(File.dirname(__FILE__), '..')
|
5
|
+
|
6
|
+
namespace :yard do
|
7
|
+
|
8
|
+
cmd = lambda do |command|
|
9
|
+
puts ">> executing: #{command}"
|
10
|
+
system command or raise "#{command} failed"
|
11
|
+
end
|
12
|
+
|
13
|
+
desc 'Pushes generated documentation to github pages: http://ruby-concurrency.github.io/thread_safe/'
|
14
|
+
task :push => [:setup, :yard] do
|
15
|
+
|
16
|
+
message = Dir.chdir(root) do
|
17
|
+
`git log -n 1 --oneline`.strip
|
18
|
+
end
|
19
|
+
puts "Generating commit: #{message}"
|
20
|
+
|
21
|
+
Dir.chdir "#{root}/yardoc" do
|
22
|
+
cmd.call "git add -A"
|
23
|
+
cmd.call "git commit -m '#{message}'"
|
24
|
+
cmd.call 'git push origin gh-pages'
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
|
29
|
+
desc 'Setups second clone in ./yardoc dir for pushing doc to github'
|
30
|
+
task :setup do
|
31
|
+
|
32
|
+
unless File.exist? "#{root}/yardoc/.git"
|
33
|
+
cmd.call "rm -rf #{root}/yardoc" if File.exist?("#{root}/yardoc")
|
34
|
+
Dir.chdir "#{root}" do
|
35
|
+
cmd.call 'git clone --single-branch --branch gh-pages git@github.com:ruby-concurrency/thread_safe.git ./yardoc'
|
36
|
+
end
|
37
|
+
end
|
38
|
+
Dir.chdir "#{root}/yardoc" do
|
39
|
+
cmd.call 'git fetch origin'
|
40
|
+
cmd.call 'git reset --hard origin/gh-pages'
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
data/test/test_array.rb
CHANGED
data/test/test_cache.rb
CHANGED
data/test/test_hash.rb
CHANGED
data/test/test_helper.rb
CHANGED
@@ -1,14 +1,35 @@
|
|
1
|
-
|
2
|
-
require '
|
3
|
-
|
1
|
+
unless defined?(JRUBY_VERSION)
|
2
|
+
require 'simplecov'
|
3
|
+
require 'coveralls'
|
4
|
+
|
5
|
+
SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[
|
6
|
+
SimpleCov::Formatter::HTMLFormatter,
|
7
|
+
Coveralls::SimpleCov::Formatter
|
8
|
+
]
|
9
|
+
|
10
|
+
SimpleCov.start do
|
11
|
+
project_name 'thread_safe'
|
12
|
+
|
13
|
+
add_filter '/examples/'
|
14
|
+
add_filter '/pkg/'
|
15
|
+
add_filter '/test/'
|
16
|
+
add_filter '/tasks/'
|
17
|
+
add_filter '/yard-template/'
|
18
|
+
add_filter '/yardoc/'
|
19
|
+
|
20
|
+
command_name 'Mintest'
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
4
24
|
require 'minitest/autorun'
|
5
25
|
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
26
|
+
require 'minitest/reporters'
|
27
|
+
Minitest::Reporters.use! Minitest::Reporters::SpecReporter.new(color: true)
|
28
|
+
|
29
|
+
require 'thread'
|
30
|
+
require 'thread_safe'
|
31
|
+
|
32
|
+
THREADS = (RUBY_ENGINE == 'ruby' ? 100 : 10)
|
12
33
|
|
13
34
|
if defined?(JRUBY_VERSION) && ENV['TEST_NO_UNSAFE']
|
14
35
|
# to be used like this: rake test TEST_NO_UNSAFE=true
|
data/thread_safe.gemspec
CHANGED
@@ -7,7 +7,7 @@ Gem::Specification.new do |gem|
|
|
7
7
|
gem.email = ["headius@headius.com", "thedarkone2@gmail.com"]
|
8
8
|
gem.description = %q{Thread-safe collections and utilities for Ruby}
|
9
9
|
gem.summary = %q{A collection of data structures and utilities to make thread-safe programming in Ruby easier}
|
10
|
-
gem.homepage = "https://github.com/
|
10
|
+
gem.homepage = "https://github.com/ruby-concurrency/thread_safe"
|
11
11
|
|
12
12
|
gem.files = `git ls-files`.split($\)
|
13
13
|
gem.files += ['lib/thread_safe/jruby_cache_backend.jar'] if defined?(JRUBY_VERSION)
|
@@ -20,7 +20,7 @@ Gem::Specification.new do |gem|
|
|
20
20
|
gem.version = ThreadSafe::VERSION
|
21
21
|
gem.license = "Apache-2.0"
|
22
22
|
|
23
|
-
gem.add_development_dependency 'atomic',
|
23
|
+
gem.add_development_dependency 'atomic', '= 1.1.16'
|
24
24
|
gem.add_development_dependency 'rake'
|
25
25
|
gem.add_development_dependency 'minitest', '>= 4'
|
26
26
|
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
/* Override this file with custom rules */
|
2
|
+
|
3
|
+
body {
|
4
|
+
line-height: 18px;
|
5
|
+
}
|
6
|
+
|
7
|
+
.docstring code, .docstring .object_link a, #filecontents code {
|
8
|
+
padding: 0px 3px 1px 3px;
|
9
|
+
border: 1px solid #eef;
|
10
|
+
background: #f5f5ff;
|
11
|
+
}
|
12
|
+
|
13
|
+
#filecontents pre code, .docstring pre code {
|
14
|
+
border: none;
|
15
|
+
background: none;
|
16
|
+
padding: 0;
|
17
|
+
}
|
18
|
+
|
19
|
+
#filecontents pre.code, .docstring pre.code, .tags pre.example, .docstring code, .docstring .object_link a,
|
20
|
+
#filecontents code {
|
21
|
+
-moz-border-radius: 2px;
|
22
|
+
-webkit-border-radius: 2px;
|
23
|
+
}
|
24
|
+
|
25
|
+
/* syntax highlighting */
|
26
|
+
.source_code {
|
27
|
+
display: none;
|
28
|
+
padding: 3px 8px;
|
29
|
+
border-left: 8px solid #ddd;
|
30
|
+
margin-top: 5px;
|
31
|
+
}
|
32
|
+
|
33
|
+
#filecontents pre.code, .docstring pre.code, .source_code pre {
|
34
|
+
font-family: monospace;
|
35
|
+
}
|
36
|
+
|
37
|
+
#filecontents pre.code, .docstring pre.code {
|
38
|
+
display: block;
|
39
|
+
}
|
40
|
+
|
41
|
+
.source_code .lines {
|
42
|
+
padding-right: 12px;
|
43
|
+
color: #555;
|
44
|
+
text-align: right;
|
45
|
+
}
|
46
|
+
|
47
|
+
#filecontents pre.code, .docstring pre.code,
|
48
|
+
.tags pre.example {
|
49
|
+
padding: 5px 12px;
|
50
|
+
margin-top: 4px;
|
51
|
+
border: 1px solid #eef;
|
52
|
+
background: #f5f5ff;
|
53
|
+
}
|
54
|
+
|
55
|
+
pre.code {
|
56
|
+
color: #000;
|
57
|
+
}
|
58
|
+
|
59
|
+
pre.code .info.file {
|
60
|
+
color: #555;
|
61
|
+
}
|
62
|
+
|
63
|
+
pre.code .val {
|
64
|
+
color: #036A07;
|
65
|
+
}
|
66
|
+
|
67
|
+
pre.code .tstring_content,
|
68
|
+
pre.code .heredoc_beg, pre.code .heredoc_end,
|
69
|
+
pre.code .qwords_beg, pre.code .qwords_end,
|
70
|
+
pre.code .tstring, pre.code .dstring {
|
71
|
+
color: #036A07;
|
72
|
+
}
|
73
|
+
|
74
|
+
pre.code .fid,
|
75
|
+
pre.code .rubyid_new,
|
76
|
+
pre.code .rubyid_to_s,
|
77
|
+
pre.code .rubyid_to_sym,
|
78
|
+
pre.code .rubyid_to_f,
|
79
|
+
pre.code .rubyid_to_i,
|
80
|
+
pre.code .rubyid_each {
|
81
|
+
color: inherit;
|
82
|
+
}
|
83
|
+
|
84
|
+
pre.code .comment {
|
85
|
+
color: #777;
|
86
|
+
font-style: italic;
|
87
|
+
}
|
88
|
+
|
89
|
+
pre.code .const, pre.code .constant {
|
90
|
+
color: inherit;
|
91
|
+
font-weight: bold;
|
92
|
+
font-style: italic;
|
93
|
+
}
|
94
|
+
|
95
|
+
pre.code .label,
|
96
|
+
pre.code .symbol {
|
97
|
+
color: #C5060B;
|
98
|
+
}
|
99
|
+
|
100
|
+
pre.code .kw,
|
101
|
+
pre.code .rubyid_require,
|
102
|
+
pre.code .rubyid_extend,
|
103
|
+
pre.code .rubyid_include,
|
104
|
+
pre.code .int {
|
105
|
+
color: #0000FF;
|
106
|
+
}
|
107
|
+
|
108
|
+
pre.code .ivar {
|
109
|
+
color: #660E7A;
|
110
|
+
}
|
111
|
+
|
112
|
+
pre.code .gvar,
|
113
|
+
pre.code .rubyid_backref,
|
114
|
+
pre.code .rubyid_nth_ref {
|
115
|
+
color: #6D79DE;
|
116
|
+
}
|
117
|
+
|
118
|
+
pre.code .regexp, .dregexp {
|
119
|
+
color: #036A07;
|
120
|
+
}
|
121
|
+
|
122
|
+
pre.code a {
|
123
|
+
border-bottom: 1px dotted #bbf;
|
124
|
+
}
|
125
|
+
|