因为本地某个程序需要依赖准确的北京时间,程序运行的时候获取的是计算机的本地时间,但计算机的本地时间跟准确的北京时间可能存在延迟或超前,Windows自带了时钟同步机制,但他也存在问题,所以故事就从Windows时钟同步的坑说起。

    计算机内部都有一个叫做「晶体振荡器」的东西,给它加上电压,它就会以固定的频率振动,然后根据一定的算法生成时间。但这个振动频率的「稳定性」,取决于它的制造工艺,以及外界环境的影响,外界环境包括电压和温度。比如,一台计算机时间久了主板上的电池亏电,就会出现时间走着走着就不准的情况。

    所以在联网的条件下,Windows会以一定的时间频率(默认为1周)来跟时间服务器进行同步。这个频率可以通过注册表进行修改。方法如下:

  1. 在cmd输入“regedit"打开注册表,定位到”HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\W32Time\TimeProviders\NtpClient“节点下面,双击 SpecialPollInterval 键值,将对话框中的“基数栏”选择到“十进制”上,将此值的数值数据604800改为43200并选为“十进制(D)”键值意思是时间同步的间隔,单位为秒,原来7天就是7*24*3600=604800秒,1小时就是60分钟*60秒=3600秒,这里设置为了每12小时同步一次。
  2. 然后在空白处,新建一个名为“SpecialInterval”的“DWORD”的值,双击刚刚新建的“SpecialInterval”,将数值改为1。
  3. cmd输入“services.msc ”,在服务管理器中,将Windows Time服务重新启动,并将启动类型改为“自动”。
  4. 最后,在时间管理界面,可以查看本次同步时间以及下次同步时间。

问题的产生


     尽管上述调整了自动跟时间服务器同步的时间频率,但是同步时,由于网络原因或者授时服务器没有响应,可能会出现超时导致时间无法同步成功,尽管同步失败,这里仍然会假模假样显示“时钟在xxx与xx同步成功”,它只是执行了同步操作,并不管是否最终同步成功,典型只管杀不管埋,并且同步失败也没有重试机制。由于我们的服务器时间比较久,可能是主板电池亏电,当时的本地时间比准确的北京时间慢了大概4~5秒,而Windows自动同步虽然显示成功,但实际上由于超时没有响应而导致同步失败,从而产生了事故

如何准确的获取时间


    如果有钞能力或者对时间的精确度要求特别高,可以购买GPS铷原子钟设备先从GPS上(铯原子钟)获取最新时间,然后在本地通过铷原子钟产生精确的时间,最后获取然后同步到计算机内。

▲ Windows时间同步服务

▲ GPS铷原子钟

    如果没有以上金钱或者对精度的要求,只能退而求其次,从Internet获取时间了。

Too simple, Sometimes naive


      从Internet获取时间,一开始自然就会想那就是找一个授时中心的网站,通来解析HTML获取里面的时间,比如下面这个页面。

    这里面有比较严重的问题,比如获取网页有延迟,解析也有延迟。获取到了返回的时间后,然后解析,这时候真实的时间还在往前走,从而导致不准确。而且这类网页的时间,一般都是在刷新的时候,才会从服务端获取,刷新完成之后,就是在本地通过JavaScript来更新了,这就依靠了本地系统时间。能够验证的是,一些时间发布网站,如果一直开着,一段时间之后,可以发现时间变得不准确。

NTP协议


    那么如何以正确的姿势来进行时间同步呢?这就不得不提NTP(Network Time Protocol )网络时间协议。Windows里面的时间同步就是使用的NTP协议,它可以提供高精准度的时间校正(LAN上与标准时间差小于1毫秒,WAN上几十毫秒),这个协议rfc4330的规范文档里写的非常详细,它有一个子集SNTP( Simple Network Time Protocol ),只考虑和一个Server对时,一般在客户端软件使用。

    NTP的较时原理是,每一个NTP通信包内包含对方的接收和发送时间戳(TimeStamp)、我方的接收和发送时间戳。在收到上述包后即可计算出时间的偏差量与网络延迟。时间信息的传输都使用UDP协议,服务端口为123,所以看到网上使用TCP来使用NTP的方法,都是虚假的NTP,大家注意识别。

    在上述协议文档里,每一个NTP包的格式如下:

    每一个NTP包=NTP头(16个字节byte,8bit)+4个时间戳(每个8字节byte,64bit)=48个字节。

    NTP头的格式如上图,包括:LI | VN | Mode | Stratum | Poll | Precision | Root Delay | Root Dispersion | Reference Identifier

  • LI(Leap Indicator):长度为2 bit,值为“11”时表示告警状态,时钟未被同步。为其他值时NTP本身不做处理。
  • VN(Version Number):长度为3bit,表示NTP的版本号,目前的最新版本为3,SNTP版本号为4。
  • Mode:长度为3bit,表示NTP的工作模式。不同的值所表示的含义分别是:0未定义、1表示主动对等体模式、2表示被动对等体模式、3表示客户模式、4表示服务器模式、5表示广播模式或组播模式、6表示此报文为NTP控制报文、7预留给内部使用。如果是客户端,这里填3。
  • Stratum:长度为8bit,系统时钟的层数,取值范围为1~16,它定义了时钟的准确度。层数为1的时钟准确度最高,准确度从1到16依次递减,层数为16的时钟处于未同步状态,不能作为参考时钟。
  • Poll:长度为8bit,轮询时间,即两个连续NTP报文之间的时间间隔。
  • Precision:长度为8bit,系统时钟的精度。
  • Root Delay:长度为32bit,本地到主参考时钟源的往返时间。
  • Root Dispersion:长度为32bit,系统时钟相对于主参考时钟的最大误差。
  • Reference Identifier:长度为32bit,参考时钟源的标识。
  • Reference Timestamp:长度为64bit,系统时钟最后一次被设定或更新的时间。
  • Originate Timestamp:长度为64bit,NTP请求报文离开发送端时发送端的本地时间。
  • Receive Timestamp:长度为64bit,NTP请求报文到达接收端时接收端的本地时间。
  • Transmit Timestamp:长度为64bit,应答报文离开应答者时应答者的本地时间。
  • Key Identifier:长度32bit,验证信息。

    4个时间戳(TimeStamps)共32个字节,他们是:

  • Original Timestamp, 记为T1,为客户端发送请求的时间。
  • Receive Timestamp, 记为T2, 为服务端接收到请求的时间。
  • Transmit Timestamp, 记为T3, 为服务端答复时间。
  • Destination Timestamp, 记为T4, 位客户端收到答复的时间,这个时间一般在客户端收到服务端返回值时,自己记录。

    协议中的时间格式,文档里也有说明,前32位,表示1900以来的秒数,后32位bit,表示秒下的部分,他是伟描述的4294.967296(=2^32/10^6)倍。

    有了以上4个时间,那么就可以计算:

  • 网络延迟delay: (T2-T1)+(T4-T3),这个就是消息在网络中来回传输的时间。
  • 客户端与服务端的时间差,offset=[(T2-T1)+(T3-T4)]/2,两个时间差取平均。

    在获得了时间差之后,客户端本地,只需要将本地时间加上这个时间差(T4+offset),就可以得到准确的时间。当然如果精度要求不高,可以直接使用服务端返回的时间加上网络延迟的一半(T3+delay/2)也行。

    这里举例说明,Device A和Device B通过网络相连,它们都有自己独立的系统时钟,需要通过NTP实现各自系统时钟的自动同步。为便于理解,作如下假设:

  • 在Device A和Device B的系统时钟同步之前,Device A的时钟设定为10:00:00am,Device B的时钟设定为11:00:00am。
  • Device B作为NTP时间服务器,即Device A将使自己的时钟与Device B的时钟同步。
  • NTP报文在Device A和Device B之间单向传输所需要的时间为1秒。

系统时钟同步的工作过程如下: 

  1. Device A发送一个NTP报文给Device B,该报文带有它离开Device A时的时间戳,该时间戳为10:00:00am(T1)。
  2. 当此NTP报文到达Device B时,Device B加上自己的时间戳,该时间戳为11:00:01am(T2)。
  3. 当此NTP报文离开Device B时,Device B再加上自己的时间戳,该时间戳为11:00:02am(T3)。
  4. 当Device A接收到该响应报文时,Device A的本地时间为10:00:03am(T4)。

至此,Device A已经拥有足够的信息来计算两个重要的参数:
    NTP报文的往返时延Delay=(T4-T1)-(T3-T2)=2秒。
    Device A相对Device B的时间差offset=((T2-T1)+(T3-T4))/2=1小时。
    这样,Device A就能够根据这些信息来设定自己的时钟,使之与Device B的时钟同步。

SNTP协议的实现


    原理清楚了,实现起来并不难,难点主要在于请求头的设置,以及从NTP包里对时间的解析,在GitHub上有很多项目,比如GuerrillaNtp,我们可以直接拿过来使用。使用方法也很简单:

// query the SNTP server
TimeSpan offset;
try
{
    using (var ntp = new NtpClient(Dns.GetHostAddresses("time.windows.com ")[0]))
        offset = ntp.GetCorrectionOffset();
}
catch (Exception ex)
{
    // timeout or bad SNTP reply
    offset = TimeSpan.Zero;
}

// use the offset throughout your app
DateTime accurateTime = DateTime.Now + offset;

     这里简单说明一下代码里的具体实现,他的请求部分代码为:

 public NtpPacket Query() { return Query(new NtpPacket()); }

      这里面New了一个NtpPacket,这个Packet是一个48字节的数组,在New的时候,初始化了三个参数:

public NtpPacket()
            : this(new byte[48])
{
       Mode = NtpMode.Client;
       VersionNumber = 4;
       TransmitTimestamp = DateTime.UtcNow;
}

public NtpMode Mode
{
      get { return (NtpMode)(Bytes[0] & 0x07); }
      set { Bytes[0] = (byte)((Bytes[0] & ~0x07) | (int)value); }
}

public int VersionNumber
{
       get { return (Bytes[0] & 0x38) >> 3; }
       set { Bytes[0] = (byte)((Bytes[0] & ~0x38) | value << 3); }
}

public DateTime? TransmitTimestamp { get { return GetDateTime64(40); } set { SetDateTime64(40, value); } }

     分别是模式:填的客户端,值为3;版本号,目前SNTP的最新版本号为4;客户端发送请求时间,这3个参数必填,其他参数保持默认即可。

     然后是发起请求:

public NtpClient(IPEndPoint endpoint) : this(endpoint, TimeSpan.FromSeconds(1)) { }

public NtpClient(IPEndPoint endpoint, TimeSpan timeout)
{
    socket = new Socket(endpoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
    Timeout = timeout;
    try
    {
        socket.Connect(endpoint);
    }
    catch
    {
        socket.Dispose();
        throw;
    }
}

public NtpPacket Query(NtpPacket request)
{
    request.ValidateRequest();
    socket.Send(request.Bytes);
    var response = new byte[160];
    int received = socket.Receive(response);
    var truncated = new byte[received];
    Array.Copy(response, truncated, received);
    NtpPacket reply = new NtpPacket(truncated) { DestinationTimestamp = DateTime.UtcNow };
    reply.ValidateReply(request);
    return reply;
}

    在Query方法里,首先验证了请求数据是否正确,然后使用socket发送该请求,随后,新建了一个response数组,用来接收返回,然后将返回数据response,复制到truncated数组中,然后新建了一个NetPacket,并将DestinationTimestamp设置为了当前接收授时服务器返回数据时的系统时间,所有的时间解析,都是在NtpPacket里面完成。

public DateTime? ReferenceTimestamp { get { return GetDateTime64(16); } set { SetDateTime64(16, value); } }
public DateTime? OriginTimestamp { get { return GetDateTime64(24); } set { SetDateTime64(24, value); } }
public DateTime? ReceiveTimestamp { get { return GetDateTime64(32); } set { SetDateTime64(32, value); } }
public DateTime? TransmitTimestamp { get { return GetDateTime64(40); } set { SetDateTime64(40, value); } }
public DateTime? DestinationTimestamp { get; set; }

public TimeSpan RoundTripTime
{
    get
    {
        CheckTimestamps();
        return (ReceiveTimestamp.Value - OriginTimestamp.Value) + (DestinationTimestamp.Value - TransmitTimestamp.Value);
    }
}

public TimeSpan CorrectionOffset
{
    get
    {
        CheckTimestamps();
        return TimeSpan.FromTicks(((ReceiveTimestamp.Value - OriginTimestamp.Value) + (TransmitTimestamp.Value - DestinationTimestamp.Value)).Ticks / 2);
    }
}

    CorrectionOffset和RoundTripTime分别表示本机和时间服务器的时差和网络延时。

    获取到最新时间之后,可以调用Windows API,将获取的最新时间,设置回计算机。

class SystemTimeHelper
{
    internal struct SYSTEMTIME
    {
        public ushort wYear;
        public ushort wMonth;
        public ushort wDayOfWeek;
        public ushort wDay;
        public ushort wHour;
        public ushort wMinute;
        public ushort wSecond;
        public ushort wMilliseconds;

        /// <summary>
        /// 从System.DateTime转换。
        /// </summary>
        /// <param name="time">System.DateTime类型的时间。</param>
        public void FromDateTime(DateTime time)
        {
            wYear = (ushort)time.Year;
            wMonth = (ushort)time.Month;
            wDayOfWeek = (ushort)time.DayOfWeek;
            wDay = (ushort)time.Day;
            wHour = (ushort)time.Hour;
            wMinute = (ushort)time.Minute;
            wSecond = (ushort)time.Second;
            wMilliseconds = (ushort)time.Millisecond;
        }
        /// <summary>
        /// 转换为System.DateTime类型。
        /// </summary>
        /// <returns></returns>
        public DateTime ToDateTime()
        {
            return new DateTime(wYear, wMonth, wDay, wHour, wMinute, wSecond, wMilliseconds);
        }
        /// <summary>
        /// 静态方法。转换为System.DateTime类型。
        /// </summary>
        /// <param name="time">SYSTEMTIME类型的时间。</param>
        /// <returns></returns>
        public static DateTime ToDateTime(SYSTEMTIME time)
        {
            return time.ToDateTime();
        }
    }

    [DllImport("Kernel32.dll")]
    public static extern bool SetLocalTime(ref SYSTEMTIME Time);
    [DllImport("Kernel32.dll")]
    public static extern void GetLocalTime(ref SYSTEMTIME Time);

    public static void SetLocalMachineTime(DateTime dt)
    {
        //转换System.DateTime到SYSTEMTIME
        SYSTEMTIME st = new SYSTEMTIME();
        st.FromDateTime(dt);
        //调用Win32 API设置系统时间
        SetLocalTime(ref st);
    }

    public static DateTime GetLocalMachineTime()
    {
        //转换SYSTEMTIME到System.DateTime
        SYSTEMTIME st = new SYSTEMTIME();
        GetLocalTime(ref st);
        return st.ToDateTime();
    }
}

    最后,需要说明的是,要修改计算机时间,需要以管理员权限运行,可以在“项目”上右键,“添加”->"新建项",选择“应用程序清单文件”,然后修改以下语句:

<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
  <security>
    <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
      <!-- UAC 清单选项
             如果想要更改 Windows 用户帐户控制级别,请使用
             以下节点之一替换 requestedExecutionLevel 节点。n
        <requestedExecutionLevel  level="asInvoker" uiAccess="false" />
        <requestedExecutionLevel  level="requireAdministrator" uiAccess="false" />
        <requestedExecutionLevel  level="highestAvailable" uiAccess="false" />

            指定 requestedExecutionLevel 元素将禁用文件和注册表虚拟化。
            如果你的应用程序需要此虚拟化来实现向后兼容性,则删除此
            元素。
        -->
      <requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
    </requestedPrivileges>
  </security>
</trustInfo>

    将“requestedExecutionLevel”的级别改为"requireAdministrator"即可。

总结


    对于一些严重依赖北京时间的应用程序,需要注意到本地计算机时间的不准确性,Windows提供了时间同步功能,可以通过修改注册表的方式来自行定制时间同步的频率,但是它存在的一个缺点是,假如由于网络原因,或者授时服务器没有响应,会导致同步失败,Windows并没有提供重试机制,并且在界面上仍然会显示同步成功,在一些情况下会产生严重的问题。本文简单介绍了NTP网络时间协议,并提供了其简化版本SNTP的C#实现,通过这一协议我们能够更加灵活的控制本地Windows系统时间与授时服务器的同步。

 

参考

  1. robertvazan/guerrillantp: Simple NTP (SNTP) client library providing .NET applications with accurate network time. (github.com)
  2. How the Windows Time Service Works | Microsoft Docs
  3. rfc4330 (ietf.org)
  4. NTP协议和算法 (bjtime.cn)