diff --git a/tornado/ioloop.py b/tornado/ioloop.py
index f6ec177b..48700139 100644
--- a/tornado/ioloop.py
+++ b/tornado/ioloop.py
@@ -1213,11 +1213,31 @@ class PeriodicCallback(object):
 
     def _schedule_next(self):
         if self._running:
-            current_time = self.io_loop.time()
-
-            if self._next_timeout <= current_time:
-                callback_time_sec = self.callback_time / 1000.0
-                self._next_timeout += (math.floor((current_time - self._next_timeout) /
-                                                  callback_time_sec) + 1) * callback_time_sec
-
+            self._update_next(self.io_loop.time())
             self._timeout = self.io_loop.add_timeout(self._next_timeout, self._run)
+
+    def _update_next(self, current_time):
+        callback_time_sec = self.callback_time / 1000.0
+        if self._next_timeout <= current_time:
+            # The period should be measured from the start of one call
+            # to the start of the next. If one call takes too long,
+            # skip cycles to get back to a multiple of the original
+            # schedule.
+            self._next_timeout += (math.floor((current_time - self._next_timeout) /
+                                              callback_time_sec) + 1) * callback_time_sec
+        else:
+            # If the clock moved backwards, ensure we advance the next
+            # timeout instead of recomputing the same value again.
+            # This may result in long gaps between callbacks if the
+            # clock jumps backwards by a lot, but the far more common
+            # scenario is a small NTP adjustment that should just be
+            # ignored.
+            #
+            # Note that on some systems if time.time() runs slower
+            # than time.monotonic() (most common on windows), we
+            # effectively experience a small backwards time jump on
+            # every iteration because PeriodicCallback uses
+            # time.time() while asyncio schedules callbacks using
+            # time.monotonic().
+            # https://github.com/tornadoweb/tornado/issues/2333
+            self._next_timeout += callback_time_sec

ioloop: PeriodicCallback executes too often on windows
## Here is the code:
import math

import logging
from crontab import CronTab
from tornado.ioloop import PeriodicCallback, IOLoop

logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')


class CronTabCallback(PeriodicCallback):

    def __init__(self, callback, schedule):
        self._callback = callback
        self._crontab = CronTab(schedule)
        super(CronTabCallback, self).__init__(self.run, self._calc_callbacktime())

    def _calc_callbacktime(self, now=None):
        return math.ceil(self._crontab.next(now)) * 1000.0

    def run(self):
        return self._callback()

    def _schedule_next(self):
        self.callback_time = self._calc_callbacktime()
        logging.info('calc ---------------------')
        logging.info('delay %s' % self.callback_time)
        logging.info('last execute %s' % self._next_timeout)

        last = self._next_timeout
        super(CronTabCallback, self)._schedule_next()

        if last == self._next_timeout:
            logging.error('error !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!')

        logging.info('current: %s' % self.io_loop.time())
        logging.info('calc next: %s' % self._next_timeout)
        logging.info('--------------------------\n')


def crontab(schedule):
    def decorator(func):
        CronTabCallback(func, schedule).start()
        return func

    return decorator


@crontab('*/1 * * * *')
def run():
    logging.info('execute ... \n')


if __name__ == '__main__':
    IOLoop.current().start()

## Here is the console log
2018-03-30 11:33:00,311 - asyncio - DEBUG - Using selector: SelectSelector
2018-03-30 11:33:00,316 - root - INFO - calc ---------------------
2018-03-30 11:33:00,316 - root - INFO - delay 60000.0
2018-03-30 11:33:00,316 - root - INFO - last execute 1522380780.3169544
2018-03-30 11:33:00,316 - root - INFO - current: 1522380780.3169544
2018-03-30 11:33:00,316 - root - INFO - **calc next: 1522380840.3169544**
2018-03-30 11:33:00,316 - root - INFO - --------------------------

**2018-03-30 11:34:00,313** - root - INFO - execute ... 

2018-03-30 11:34:00,313 - root - INFO - calc ---------------------
2018-03-30 11:34:00,313 - root - INFO - delay 60000.0
2018-03-30 11:34:00,313 - root - INFO - last execute 1522380840.3169544
2018-03-30 11:34:00,313 - root - ERROR - error !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
2018-03-30 11:34:00,313 - root - INFO - current: 1522380840.3139544
2018-03-30 11:34:00,313 - root - INFO - calc next: 1522380840.3169544
2018-03-30 11:34:00,313 - root - INFO - --------------------------

2018-03-30 11:34:00,318 - root - INFO - execute ... 

2018-03-30 11:34:00,318 - root - INFO - calc ---------------------
2018-03-30 11:34:00,318 - root - INFO - delay 60000.0
2018-03-30 11:34:00,318 - root - INFO - last execute 1522380840.3169544
2018-03-30 11:34:00,318 - root - INFO - current: 1522380840.3189545
2018-03-30 11:34:00,318 - root - INFO - calc next: 1522380900.3169544
2018-03-30 11:34:00,318 - root - INFO - --------------------------

## Environment:
OS: windows 7
Python:  python 3.6
Dependent library: crontab 0.22.0

Tornado 4.5.1 python2 (ok) python3(ok)
Tornado 5.0.1 python2 (ok) python3 (linux ok, window has issue)
diff --git a/tornado/test/ioloop_test.py b/tornado/test/ioloop_test.py
index 09f71c5d..9f7c1847 100644
--- a/tornado/test/ioloop_test.py
+++ b/tornado/test/ioloop_test.py
@@ -789,6 +789,62 @@ class TestPeriodicCallback(unittest.TestCase):
         io_loop.close()
 
 
+class TestPeriodicCallbackMath(unittest.TestCase):
+    def simulate_calls(self, pc, durations):
+        """Simulate a series of calls to the PeriodicCallback.
+
+        Pass a list of call durations in seconds (negative values
+        work to simulate clock adjustments during the call, or more or
+        less equivalently, between calls). This method returns the
+        times at which each call would be made.
+        """
+        calls = []
+        now = 1000
+        pc._next_timeout = now
+        for d in durations:
+            pc._update_next(now)
+            calls.append(pc._next_timeout)
+            now = pc._next_timeout + d
+        return calls
+
+    def test_basic(self):
+        pc = PeriodicCallback(None, 10000)
+        self.assertEqual(self.simulate_calls(pc, [0] * 5),
+                         [1010, 1020, 1030, 1040, 1050])
+
+    def test_overrun(self):
+        # If a call runs for too long, we skip entire cycles to get
+        # back on schedule.
+        call_durations = [9, 9, 10, 11, 20, 20, 35, 35, 0, 0, 0]
+        expected = [
+            1010, 1020, 1030,  # first 3 calls on schedule
+            1050, 1070,  # next 2 delayed one cycle
+            1100, 1130,  # next 2 delayed 2 cycles
+            1170, 1210,  # next 2 delayed 3 cycles
+            1220, 1230,  # then back on schedule.
+        ]
+
+        pc = PeriodicCallback(None, 10000)
+        self.assertEqual(self.simulate_calls(pc, call_durations),
+                         expected)
+
+    def test_clock_backwards(self):
+        pc = PeriodicCallback(None, 10000)
+        # Backwards jumps are ignored, potentially resulting in a
+        # slightly slow schedule (although we assume that when
+        # time.time() and time.monotonic() are different, time.time()
+        # is getting adjusted by NTP and is therefore more accurate)
+        self.assertEqual(self.simulate_calls(pc, [-2, -1, -3, -2, 0]),
+                         [1010, 1020, 1030, 1040, 1050])
+
+        # For big jumps, we should perhaps alter the schedule, but we
+        # don't currently. This trace shows that we run callbacks
+        # every 10s of time.time(), but the first and second calls are
+        # 110s of real time apart because the backwards jump is
+        # ignored.
+        self.assertEqual(self.simulate_calls(pc, [-100, 0, 0]),
+                         [1010, 1020, 1030])
+
 class TestIOLoopConfiguration(unittest.TestCase):
     def run_python(self, *statements):
         statements = [

pip install -e .[dev]
parse_log_pytest
pytest
pytest flake8
apt-get update apt-get install -y gcc
3.6
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
attrs @ file:///opt/conda/conda-bld/attrs_1642510447205/work
certifi==2021.5.30
flake8==5.0.4
importlib-metadata==4.2.0
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
mccabe==0.7.0
more-itertools @ file:///tmp/build/80754af9/more-itertools_1637733554872/work
packaging @ file:///tmp/build/80754af9/packaging_1637314298585/work
pluggy @ file:///tmp/build/80754af9/pluggy_1615976315926/work
py @ file:///opt/conda/conda-bld/py_1644396412707/work
pycodestyle==2.9.1
pyflakes==2.5.0
pyparsing @ file:///tmp/build/80754af9/pyparsing_1635766073266/work
pytest==6.2.4
toml @ file:///tmp/build/80754af9/toml_1616166611790/work
-e git+https://github.com/tornadoweb/tornado.git@35a538f50e704e348926e1b113bc03328a1da9f2#egg=tornado
typing_extensions @ file:///opt/conda/conda-bld/typing_extensions_1647553014482/work
zipp @ file:///tmp/build/80754af9/zipp_1633618647012/work

name: tornado
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - attrs=21.4.0=pyhd3eb1b0_0
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - importlib_metadata=4.8.1=hd3eb1b0_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - more-itertools=8.12.0=pyhd3eb1b0_0
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - packaging=21.3=pyhd3eb1b0_0
  - pip=21.2.2=py36h06a4308_0
  - pluggy=0.13.1=py36h06a4308_0
  - py=1.11.0=pyhd3eb1b0_0
  - pyparsing=3.0.4=pyhd3eb1b0_0
  - pytest=6.2.4=py36h06a4308_2
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - toml=0.10.2=pyhd3eb1b0_0
  - typing_extensions=4.1.1=pyh06a4308_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zipp=3.6.0=pyhd3eb1b0_0
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - flake8==5.0.4
      - importlib-metadata==4.2.0
      - mccabe==0.7.0
      - pycodestyle==2.9.1
      - pyflakes==2.5.0
prefix: /opt/conda/envs/tornado

tornado/test/ioloop_test.py::TestPeriodicCallbackMath::test_basic tornado/test/ioloop_test.py::TestPeriodicCallbackMath::test_clock_backwards tornado/test/ioloop_test.py::TestPeriodicCallbackMath::test_overrun
tornado/test/ioloop_test.py::TestIOLoop::test_add_callback_from_signal tornado/test/ioloop_test.py::TestIOLoop::test_add_callback_from_signal_other_thread tornado/test/ioloop_test.py::TestIOLoop::test_add_callback_return_sequence tornado/test/ioloop_test.py::TestIOLoop::test_add_callback_wakeup tornado/test/ioloop_test.py::TestIOLoop::test_add_callback_wakeup_other_thread tornado/test/ioloop_test.py::TestIOLoop::test_add_callback_while_closing tornado/test/ioloop_test.py::TestIOLoop::test_add_timeout_return tornado/test/ioloop_test.py::TestIOLoop::test_add_timeout_timedelta tornado/test/ioloop_test.py::TestIOLoop::test_call_at_return tornado/test/ioloop_test.py::TestIOLoop::test_call_later_return tornado/test/ioloop_test.py::TestIOLoop::test_close_file_object tornado/test/ioloop_test.py::TestIOLoop::test_exception_logging tornado/test/ioloop_test.py::TestIOLoop::test_exception_logging_future tornado/test/ioloop_test.py::TestIOLoop::test_exception_logging_native_coro tornado/test/ioloop_test.py::TestIOLoop::test_handle_callback_exception tornado/test/ioloop_test.py::TestIOLoop::test_handler_callback_file_object tornado/test/ioloop_test.py::TestIOLoop::test_mixed_fd_fileobj tornado/test/ioloop_test.py::TestIOLoop::test_multiple_add tornado/test/ioloop_test.py::TestIOLoop::test_read_while_writeable tornado/test/ioloop_test.py::TestIOLoop::test_reentrant tornado/test/ioloop_test.py::TestIOLoop::test_remove_handler_from_handler tornado/test/ioloop_test.py::TestIOLoop::test_remove_timeout_after_fire tornado/test/ioloop_test.py::TestIOLoop::test_remove_timeout_cleanup tornado/test/ioloop_test.py::TestIOLoop::test_remove_timeout_from_timeout tornado/test/ioloop_test.py::TestIOLoop::test_remove_without_add tornado/test/ioloop_test.py::TestIOLoop::test_spawn_callback tornado/test/ioloop_test.py::TestIOLoop::test_timeout_with_arguments tornado/test/ioloop_test.py::TestIOLoopCurrent::test_default_current tornado/test/ioloop_test.py::TestIOLoopCurrent::test_force_current tornado/test/ioloop_test.py::TestIOLoopCurrent::test_non_current tornado/test/ioloop_test.py::TestIOLoopCurrentAsync::test_clear_without_current tornado/test/ioloop_test.py::TestIOLoopAddCallback::test_pre_wrap tornado/test/ioloop_test.py::TestIOLoopAddCallback::test_pre_wrap_with_args tornado/test/ioloop_test.py::TestIOLoopAddCallbackFromSignal::test_pre_wrap tornado/test/ioloop_test.py::TestIOLoopAddCallbackFromSignal::test_pre_wrap_with_args tornado/test/ioloop_test.py::TestIOLoopFutures::test_add_future_stack_context tornado/test/ioloop_test.py::TestIOLoopFutures::test_add_future_threads tornado/test/ioloop_test.py::TestIOLoopFutures::test_run_in_executor_gen tornado/test/ioloop_test.py::TestIOLoopFutures::test_run_in_executor_native tornado/test/ioloop_test.py::TestIOLoopFutures::test_set_default_executor tornado/test/ioloop_test.py::TestIOLoopRunSync::test_async_exception tornado/test/ioloop_test.py::TestIOLoopRunSync::test_async_result tornado/test/ioloop_test.py::TestIOLoopRunSync::test_current tornado/test/ioloop_test.py::TestIOLoopRunSync::test_native_coroutine tornado/test/ioloop_test.py::TestIOLoopRunSync::test_sync_exception tornado/test/ioloop_test.py::TestIOLoopRunSync::test_sync_result tornado/test/ioloop_test.py::TestIOLoopRunSync::test_timeout tornado/test/ioloop_test.py::TestIOLoopConfiguration::test_asyncio tornado/test/ioloop_test.py::TestIOLoopConfiguration::test_asyncio_main tornado/test/ioloop_test.py::TestIOLoopConfiguration::test_default
tornado/ioloop.py
tornado/ioloop.py