因为本地某个程序需要依赖准确的北京时间,程序运行的时候获取的是计算机的本地时间,但计算机的本地时间跟准确的北京时间可能存在延迟或超前,Windows自带了时钟同步机制,但他也存在问题,所以故事就从Windows时钟同步的坑说起。
计算机内部都有一个叫做「晶体振荡器」的东西,给它加上电压,它就会以固定的频率振动,然后根据一定的算法生成时间。但这个振动频率的「稳定性」,取决于它的制造工艺,以及外界环境的影响,外界环境包括电压和温度。比如,一台计算机时间久了主板上的电池亏电,就会出现时间走着走着就不准的情况。
所以在联网的条件下,Windows会以一定的时间频率(默认为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小时同步一次。
- 然后在空白处,新建一个名为“SpecialInterval”的“DWORD”的值,双击刚刚新建的“SpecialInterval”,将数值改为1。
- cmd输入“services.msc ”,在服务管理器中,将Windows Time服务重新启动,并将启动类型改为“自动”。
- 最后,在时间管理界面,可以查看本次同步时间以及下次同步时间。
问题的产生
尽管上述调整了自动跟时间服务器同步的时间频率,但是同步时,由于网络原因或者授时服务器没有响应,可能会出现超时导致时间无法同步成功,尽管同步失败,这里仍然会假模假样显示“时钟在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秒。
系统时钟同步的工作过程如下:
- Device A发送一个NTP报文给Device B,该报文带有它离开Device A时的时间戳,该时间戳为10:00:00am(T1)。
- 当此NTP报文到达Device B时,Device B加上自己的时间戳,该时间戳为11:00:01am(T2)。
- 当此NTP报文离开Device B时,Device B再加上自己的时间戳,该时间戳为11:00:02am(T3)。
- 当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系统时间与授时服务器的同步。
参考
- robertvazan/guerrillantp: Simple NTP (SNTP) client library providing .NET applications with accurate network time. (github.com)
- How the Windows Time Service Works | Microsoft Docs
- rfc4330 (ietf.org)
- NTP协议和算法 (bjtime.cn)