Unix Timestamp(Unix时间戳)

UNIX时间,或称POSIX时间是UNIX或类UNIX系统使用的时间表示方式:从UTC 1970年1月1日0时0分0秒起至现在的总秒数,不考虑闰秒。在多数Unix系统上Unix时间可以透过date +%s指令来检查。(Unix time (also known as Epoch timePOSIX timeseconds since the Epoch, or UNIX Epoch time) is a system for describing a point in time. It is the number of seconds that have elapsed since the Unix epoch.)

从这个解释可以看出来,同一时刻,在全世界任一时区,获取的Unix时间戳是相同的。

所以,针对PHP而言,time()函数获取的到时间戳与时区无关。

time ( ) : int

Returns the current time measured in the number of seconds since the Unix Epoch (January 1 1970 00:00:00 GMT).

那么,进一步延伸,对于一个给定的日期时间字符串,例如:2020-01-01 00:00:00,那么获取这个日期时间对应的时间戳就是与时区有关的。因为不同时区下的2020-01-01 00:00:00距离UTC的1970-01-01 00:00:00的时间间隔是不一样的。

strtotime ( string $datetime [, int $now = time() ] ) : int

对于PHP而言,在使用strtotime函数时,如果日期时间字符串中没有包含时区信息,那么会使用默认的时区date_default_timezone_get()。(Each parameter of this function uses the default time zone unless a time zone is specified in that parameter. Be careful not to use different time zones in each parameter unless that is intended. )

$dateStr = '2020-01-01 00:00:00';

$timezone = 'Asia/Shanghai';
date_default_timezone_set($timezone);
echo sprintf("%13s)%25s ==> %s\n", $timezone, $dateStr, strtotime($dateStr));

$dateStrWithTimezone = '2020-01-01T00:00:00+08:00';
date_default_timezone_set($timezone);//优先读取日期时间字符串里的时区信息,此处单独设置的时区对下一行的strtotime无效
echo sprintf("%13s)%25s ==> %s\n", $timezone, $dateStrWithTimezone, strtotime($dateStrWithTimezone));

$timezone = 'UTC';
date_default_timezone_set($timezone);
echo sprintf("%13s)%25s ==> %s\n", $timezone, $dateStr, strtotime($dateStr));
echo sprintf("%13s)%25s ==> %s\n", $timezone, $dateStrWithTimezone, strtotime($dateStrWithTimezone));

output:
Asia/Shanghai)2020-01-01 00:00:00 ==> 1577808000
Asia/Shanghai)2020-01-01T00:00:00+08:00 ==> 1577808000
UTC)2020-01-01 00:00:00 ==> 1577836800
UTC)2020-01-01T00:00:00+08:00 ==> 1577808000 //优先读取日期时间字符串里的时区信息,runtime设置的时区对strtotime无效

 

date ( string $format [, int $timestamp = time() ] ) : string

Format a local time/date

同样的道理,同一个时间戳在不同的时区下,对应的日期时间字符串是不一样的。

date_default_timezone_set('UTC');
$timestamp = 1577836800;//UTC 2020-01-01 00:00:00
$sourceDatetime = new \DateTime(date('Y-m-d H:i:s', $timestamp));
echo sprintf("source datetime:%s(%s)\n", $sourceDatetime->format('Y-m-d H:i:s'), $sourceDatetime->getTimezone()->getName());

$timezone = 'Asia/Shanghai';
$targetDatetime = (new \DateTime(date('Y-m-d H:i:s', $timestamp)))
    ->setTimezone(new \DateTimeZone($timezone));
echo sprintf("target datetime:%s(%s)\n", $targetDatetime->format('Y-m-d H:i:s'), $targetDatetime->getTimezone()->getName());

output:
source datetime:2020-01-01 00:00:00(UTC)
target datetime:2020-01-01 08:00:00(Asia/Shanghai)

 

References:

https://zh.wikipedia.org/wiki/UNIX时间

记录cp命令在GNU/Linux和BSD(MacOS)下表现不一致的一个行为

最后更新时间:2021-04-22

重现步骤如下:

现有的目录结构:

├── source

│   └── source.txt

├── target

 

cp -r source target1   //target1 dir not exists

GUN/BSD
├── source

│   └── source.txt

├── target

└── target1

└── source.txt

 

cp -r source target   //target dir exists

GUN/Linux BSD(MacOS)
├── source

│   └── source.txt

├── target

└── source

└── source.txt

├── source

│   └── source.txt

├── target

│   └── source.txt

测试环境:

GNU coreutils 8.22(CentOS)

GNU coreutils 8.30(Ubuntu 20.04)

BSD macOS Catalina 10.15.7

 

总结/Summary:

使用cp -r source target 复制目录时,如果目标目录不存在,则GUN/Linux&BSD(mac OS)的cp行为一致;如果目录已经存在,则BSD(mac OS)下会和目标目录不存在时行为一致,但是GUN的cp会创建在目标目录下创建子目录。

 

PS:

如果目标目录已经存在,可以使用 cp -r source/.  target (GUN/Linux下target目录存在or不存在都支持,BSD(mac OS)下target目录存在or不存在都支持) or   cp -r source/* target(仅适用于GUN/Linux下target目录存在的情况,BSD(mac OS)下语法错误)

所以简单起见:直接使用cp -r source/.  target。GUN/Linux和BSD(mac OS)都支持,并且target目录存不存在都支持。

 

Console and TTY and PTY

Console:

在早期的电脑上,往往具有带有大量开关和指示灯的面板,可以对电脑进行一些底层的操作,这个面板就叫做Console。其概念来自于管风琴的控制台。一台电脑通常只能有一个Console,很多时候是电脑主机的一部分,和CPU共享一个机柜。

 

Terminal and TTY(终端):

一台大型主机往往需要支持许多用户同时使用,每个用户所使用操作的设备,就叫做Terminal——终端,终端使用通信电缆与电脑主机连接,甚至可以通过电信网络(电话、电报线路等等)连接另一个城市的电脑。

TTY是电传打字机Teletypewriter的缩写,TTY曾经是最流行的终端设备。现在大多数场景TTY基本就是表示terminal。

Linux有7个TTY?
从控制台切换到 X 窗口,一般采用Ctrl+Alt+F7 ,为什么呢?因为系统默认定义了6个虚拟控制台(Ctrl+Alt+F1 ~ F6), 7个用于x-windows实际上,很多人一般不会需要这么多虚拟控制台的,关闭多余的控制台减少对系统资源的占用,可以自己更改配置文件减少它的数量(在支持systemd的系统里,配置文件位置:/etc/systemd/logind.conf, https://freedesktop.org/software/systemd/man/logind.conf.html,所以具体系统开启了多少个tty,要看配置文件,可能各个发行版本不一样,比如Ubuntu18.04/20.04就是默认6个,而且默认是把tty1留给了x-windows(Ctrl+Alt+F1))。

 

PTY(Pseudo TerminalPseudo TTY, or PTY, 虚拟终端/伪终端):

pts/ptmx(pts/ptmx结合使用,进而实现pty)

所谓伪终端是逻辑上的终端设备,多用于模拟终端程序。例如,我们在X Window下打开的终端,以及我们在Windows使用telnet 或ssh等方式登录Linux主机,此时均在使用pty设备(准确的说在使用pty从设备)

 

echo test > /dev/tty1会把单词test发送到连接在tty1的设备上。

1、/dev/pts0, /dev/pts1, …

2、/dev/tty0, …

3、/dev/console 表示物理终端(控制台)

4、/dev/ttyS0, …  表示串行终端

tty命令可以查看当前登录的终端类型,比如/dev/tty2,/dev/pts/0

grep不显示自己

要想在使用grep时不显示自己,常规有下面两种做法

ps -ef | grep nginx|grep -v “grep”

ps -ef | grep [n]ginx

grep -v 过滤grep自身可以通过man获取帮助。但是grep [n]ginx

是怎么实现不现实grep本身了??

寻思很久,终于在stackoverflow上看到了一个醍醐灌顶的答案。

ps -ef | grep [n]ginx 命令中,grep 命令的参数是“[n]ginx”,正则[n]ginx当然不能匹配它!

C/C++网络编程中的TCP保活

在默认的情况下,TCP连接是没有保活的心跳的。这就是说,当一个TCP的socket,客户端与服务端谁也不发送数据,会一直保持着连接。这其中如果有一方异常掉线,另一端永远也不可能知道。这对于一些服务型的程序来说,将是灾难性的后果。

  所以,必须对创建的socket,启用保活心跳,即Keepalive选项。

启用Keepalive

  对于WIN32或者Linux平台来说,设置socket的Keepalive都很简单,只需使用setsockopt设置SO_KEEPALIVE即可。

  setsockopt的函数原型在Linux环境下为:

[cpp]
  1. #include <sys/types.h>   
  2. #include <sys/socket.h>   
  3.   
  4. int setsockopt(int s, int level, int optname,  
  5.                const void *optval,  
  6.                socklen_t optlen);  

,在WIN32平台下为

[cpp]
  1. #include <winsock2.h>   
  2.   
  3. int setsockopt(int s, int level, int optname,  
  4.                const char *optval,  
  5.                int optlen);  

  因为const void *可以接受const char *型的参数,所以为了代码的跨平台编译考虑,可以采用以下代码来设置TCP的Keepalive选项。

[cpp]
  1. alive = 1;  
  2. if (setsockopt  
  3.     (fd, SOL_SOCKET, SO_KEEPALIVE, (const char *) &alive,  
  4.      sizeof alive) != 0)  
  5.   {  
  6.     log_warn (“Set keep alive error: %s.\n”, strerror (errno));  
  7.     return -1;  
  8.   }  

  这样,对于TCP的连接,就启用了系统默认值的保活心跳。

Linux环境下的TCP Keepalive参数设置

  为什么说是系统默认值的呢?因为有这样几个值,我们并没有手动设置,是采用的系统默认值。即,

  1. 多长时间发送一次保活心跳?
  2. 如果没有返回,多长时间再重试发送?
  3. 重试几次为失败?  如果是Linux操作系统,这三个值分别为
[plain]
  1. # cat /proc/sys/net/ipv4/tcp_keepalive_time  
  2. 7200  
  3. # cat /proc/sys/net/ipv4/tcp_keepalive_intvl  
  4. 75  
  5. # cat /proc/sys/net/ipv4/tcp_keepalive_probes  
  6. 9  

  这就是说,在Linux系统下,如果对于TCP的socket启用了Keepalive选项,则会在7200秒(即两个小时)没有数据后,发起KEEPALIVE报文。如果没有回应,则会在75秒后再次重试。如果重试9次均失败,则认定连接已经失效。TCP的读取操作,将返回0。

  这对于我们大多数应用来说,前两个时间值都有点太长了。

  我们可以通过重设上面三个值,来使得操作系统上运行的所有启用了Keepalive选项的TCP的socket的行为更改。

  我们也可以只针对我们自己创建的socket,重设这三个值。它们分别对应TCP_KEEPIDLE、TCP_KEEPINTL和TCP_KEEPCNT的选项值,同样可以使用setsockopt进行设置。

[cpp]
  1. #include <stdlib.h>   
  2. #include <fcntl.h>   
  3. #include <errno.h>   
  4. #include <sys/socket.h>   
  5. #include <netinet/tcp.h>   
  6. #include <netinet/in.h>   
  7. #include <netdb.h>   
  8. #include <arpa/inet.h>   
  9.   
  10. int  
  11. socket_set_keepalive (int fd)  
  12. {  
  13.   int ret, error, flag, alive, idle, cnt, intv;  
  14.   
  15.   /* Set: use keepalive on fd */  
  16.   alive = 1;  
  17.   if (setsockopt  
  18.       (fd, SOL_SOCKET, SO_KEEPALIVE, &alive,  
  19.        sizeof alive) != 0)  
  20.     {  
  21.       log_warn (“Set keepalive error: %s.\n”, strerror (errno));  
  22.       return -1;  
  23.     }  
  24.   
  25.   /* 10秒钟无数据,触发保活机制,发送保活包 */  
  26.   idle = 10;  
  27.   if (setsockopt (fd, SOL_TCP, TCP_KEEPIDLE, &idle, sizeof idle) != 0)  
  28.     {  
  29.       log_warn (“Set keepalive idle error: %s.\n”, strerror (errno));  
  30.       return -1;  
  31.     }  
  32.   
  33.   /* 如果没有收到回应,则5秒钟后重发保活包 */  
  34.   intv = 5;  
  35.   if (setsockopt (fd, SOL_TCP, TCP_KEEPINTVL, &intv, sizeof intv) != 0)  
  36.     {  
  37.       log_warn (“Set keepalive intv error: %s.\n”, strerror (errno));  
  38.       return -1;  
  39.     }  
  40.   
  41.   /* 连续3次没收到保活包,视为连接失效 */  
  42.   cnt = 3;  
  43.   if (setsockopt (fd, SOL_TCP, TCP_KEEPCNT, &cnt, sizeof cnt) != 0)  
  44.     {  
  45.       log_warn (“Set keepalive cnt error: %s.\n”, strerror (errno));  
  46.       return -1;  
  47.     }  
  48.   
  49.   return 0;  
  50. }  

WIN32环境下的TCP Keepalive参数设置

  而WIN32环境下的参数设置,就要麻烦一些,需要使用另外的一个函数WSAIoctl和一个结构struct tcp_keepalive。

  它们的原型分别为:

[cpp]
  1. #include <winsock2.h>   
  2. #include <mstcpip.h>   
  3.   
  4. int WSAIoctl(  
  5.              SOCKET s,  
  6.              DWORD dwIoControlCode,  
  7.              LPVOID lpvInBuffer,  
  8.              DWORD cbInBuffer,  
  9.              LPVOID lpvOutBuffer,  
  10.              DWORD cbOutBuffer,  
  11.              LPDWORD lpcbBytesReturned,  
  12.              LPWSAOVERLAPPED lpOverlapped,  
  13.              LPWSAOVERLAPPED_COMPLETION lpCompletionRoutine  
  14. );  
  15.   
  16. struct tcp_keepalive {  
  17.     u_long onoff;  
  18.     u_long keepalivetime;  
  19.     u_long keepaliveinterval;  
  20. };  

  在这里,使用WSAIoctl的时候,dwIoControlCode要使用SIO_KEEPALIVE_VALS,lpvOutBuffer用不上,cbOutBuffer必须设置为0。

  struct tcp_keepalive结构的参数意义为:

  onoff,是否开启KEEPALIVE; keepalivetime,多长时间触发Keepalive报文的发送; keepaliveinterval,多长时间没有回应触发下一次发送。

  注意:这里两个时间单位都是毫秒而不是秒。

[cpp]
  1. #include <winsock2.h>   
  2. #include <mstcpip.h>   
  3.   
  4. int  
  5. socket_set_keepalive (int fd)  
  6. {  
  7.   struct tcp_keepalive kavars[1] = {  
  8.       1,  
  9.       10 * 1000,        /* 10 seconds */  
  10.       5 * 1000          /* 5 seconds */  
  11.   };  
  12.   
  13.   /* Set: use keepalive on fd */  
  14.   alive = 1;  
  15.   if (setsockopt  
  16.       (fd, SOL_SOCKET, SO_KEEPALIVE, (const char *) &alive,  
  17.        sizeof alive) != 0)  
  18.     {  
  19.       log_warn (“Set keep alive error: %s.\n”, strerror (errno));  
  20.       return -1;  
  21.     }  
  22.   
  23.   if (WSAIoctl  
  24.       (fd, SIO_KEEPALIVE_VALS, kavars, sizeof kavars, NULL, sizeof (int), &ret, NULL,  
  25.        NULL) != 0)  
  26.     {  
  27.       log_warn (“Set keep alive error: %s.\n”, strerror (WSAGetLastError ()));  
  28.       return -1;  
  29.     }  
  30.   
  31.   return 0;  
  32. }  

sigwait函数

刚开始看sigwait函数,只是知道它是用来解除阻塞的信号,可是使我疑惑的是那么解除了以后为什么线程收到终止信号SIGINT的时候还是没能终止呢?于是网上找了一些资料,总的理解如下所示:

sigwait(&set, signo)监听信号集set中所包含的信号,并将其存在signo中。
   注意:sigwait函数所监听的信号在之前必须被阻塞。sigwait函数将阻塞调用他的线程,直到收到它所监听的信号发生了,然后sigwait将其从未决队列中取出(因为被阻塞了,所以肯定是未决了),但是有一点需要注意的是:它从未决队列取出之后,并不影响那个被取出的信号原来被阻塞的状态。它所做的工作只有两个:第一,监听被阻塞的信号;第二,如果所监听的信号产生了,则将其从未决队列中移出来(这里实时信号和非实时信号又有区别,体现在取出的顺序等,具体自己取网上查,这里不再详述)。在一些帖子中看到:sigwait取出未决信号之后,并将其原来的阻塞状态转为非阻塞状态,这是严重错误的,sigwait并不改变信号的阻塞与非阻塞状态,它只做上面的两个工作。(以上描述有错的话,欢迎指正)

多线程程序与fork()

多线程程序里不准使用fork

UNIX上C++程序设计守则3

准则3:多线程程序里不准使用fork

在多线程程序里,在”自身以外的线程存在的状态”下一使用fork的话,就可能引起各种各样的问题.比较典型的例子就是,fork出来的子进程可能会死锁.请不要,在不能把握问题的原委的情况下就在多线程程序里fork子进程.

能引起什么问题呢?

那看看实例吧.一执行下面的代码,在子进程的执行开始处调用doit()时,发生死锁的机率会很高.

1void* doit(void*) {
2
3    static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
4
5    pthread_mutex_lock(&mutex);
6
7    struct timespec ts = {10, 0}; nanosleep(&ts, 0); // 10秒寝る
8                                                     // 睡10秒
9
10    pthread_mutex_unlock(&mutex);
11
12    return 0;
13
14}
15
16 
17
18int main(void) {
19
20pthread_t t; 
21
22pthread_create(&t, 0, doit, 0); 
23
24                                // 做成并启动子线程
25
26    if (fork() == 0) {
27
28        
29
30        
31
32        //子进程
33
34        //在子进程被创建的瞬间,父的子进程在执行nanosleep的场合比较多
35
36        doit(0); return 0;
37
38    }
39
40pthread_join(t, 0); //
41
42                    // 等待子线程结束
43
44}
45


以下是说明死锁的理由.

一般的,fork做如下事情
   1. 父进程的内存数据会原封不动的拷贝到子进程中
   2. 子进程在单线程状态下被生成

在内存区域里,静态变量*2mutex的内存会被拷贝到子进程里.而且,父进程里即使存在多个线程,但它们也不会被继承到子进程里. fork的这两个特征就是造成死锁的原因.
译者注: 死锁原因的详细解释 —
    1. 线程里的doit()先执行.
    2. doit执行的时候会给互斥体变量mutex加锁.
    3. mutex变量的内容会原样拷贝到fork出来的子进程中(在此之前,mutex变量的内容已经被线程改写成锁定状态).
    4. 子进程再次调用doit的时候,在锁定互斥体mutex的时候会发现它已经被加锁,所以就一直等待,直到拥有该互斥体的进程释放它(实际上没有人拥有这个mutex锁).
    5. 线程的doit执行完成之前会把自己的mutex释放,但这是的mutex和子进程里的mutex已经是两份内存.所以即使释放了mutex锁也不会对子进程里的mutex造成什么影响.

例如,请试着考虑下面那样的执行流程,就明白为什么在上面多线程程序里不经意地使用fork就造成死锁了*3.
1.    在fork前的父进程中,启动了线程1和2
2.    线程1调用doit函数
3.    doit函数锁定自己的mutex
4.    线程1执行nanosleep函数睡10秒
5.    在这儿程序处理切换到线程2
6.    线程2调用fork函数
7.    生成子进程
8.    这时,子进程的doit函数用的mutex处于”锁定状态”,而且,解除锁定的线程在子进程里不存在
9.    子进程的处理开始
10.   子进程调用doit函数
11.   子进程再次锁定已经是被锁定状态的mutex,然后就造成死锁

像这里的doit函数那样的,在多线程里因为fork而引起问题的函数,我们把它叫做”fork-unsafe函数”.反之,不能引起问题的函数叫做”fork-safe函数”.虽然在一些商用的UNIX里,源于OS提供的函数(系统调用),在文档里有fork-safety的记载,但是在Linux(glibc)里当然!不会被记载.即使在POSIX里也没有特别的规定,所以那些函数是fork-safe的,几乎不能判别.不明白的话,作为unsafe考虑的话会比较好一点吧.(2004/9/12追记)Wolfram Gloger说过,调用异步信号安全函数是规格标准,所以试着调查了一下,在pthread_atforkの这个地方里有” In the meantime*5, only a short list of async-signal-safe library routines are promised to be available.”这样的话.好像就是这样.

随便说一下,malloc函数就是一个维持自身固有mutex的典型例子,通常情况下它是fork-unsafe的.依赖于malloc函数的函数有很多,例如printf函数等,也是变成fork-unsafe的.

直到目前为止,已经写上了thread+fork是危险的,但是有一个特例需要告诉大家.”fork后马上调用exec的场合,是作为一个特列不会产生问题的”. 什么原因呢..? exec函数*6一被调用,进程的”内存数据”就被临时重置成非常漂亮的状态.因此,即使在多线程状态的进程里,fork后不马上调用一切危险的函数,只是调用exec函数的话,子进程将不会产生任何的误动作.但是,请注意这里使用的”马上”这个词.即使exec前仅仅只是调用一回printf(“I’m child process”),也会有死锁的危险.
译者注:exec函数里指明的命令一被执行,改命令的内存映像就会覆盖父进程的内存空间.所以,父进程里的任何数据将不复存在.

如何规避灾难呢?
为了在多线程的程序中安全的使用fork,而规避死锁问题的方法有吗?试着考虑几个.

规避方法1:做fork的时候,在它之前让其他的线程完全终止.
在fork之前,让其他的线程完全终止的话,则不会引起问题.但这仅仅是可能的情况.还有,因为一些原因而其他线程不能结束就执行了fork的时候,就会是产生出一些解析困难的不具合的问题.

规避方法2:fork后在子进程中马上调用exec函数

(2004/9/11 追记一些忘了写的东西)
不用使用规避方法1的时候,在fork后不调用任何函数(printf等)就马上调用execl等,exec系列的函数.如果在程序里不使用”没有exec就fork”的话,这应该就是实际的规避方法吧.
译者注:笔者的意思可能是把原本子进程应该做的事情写成一个单独的程序,编译成可执行程序后由exec函数来调用.

规避方法3:”其他线程”中,不做fork-unsafe的处理 
除了调用fork的线程,其他的所有线程不要做fork-unsafe的处理.为了提高数值计算的速度而使用线程的场合*7,这可能是fork-safe的处理,但是在一般的应用程序里则不是这样的.即使仅仅是把握了那些函数是fork-safe的,做起来还不是很容易的.fork-safe函数,必须是异步信号安全函数,而他们都是能数的过来的.因此,malloc/new,printf这些函数是不能使用的.

规避方法4:使用pthread_atfork函数,在即将fork之前调用事先准备的回调函数.
使用pthread_atfork函数,在即将fork之前调用事先准备的回调函数,在这个回调函数内,协商清除进程的内存数据.但是关于OS提供的函数(例:malloc),在回调函数里没有清除它的方法.因为malloc里使用的数据结构在外部是看不见的.因此,pthread_atfork函数几乎是没有什么实用价值的.

规避方法5:在多线程程序里,不使用fork
就是不使用fork的方法.即用pthread_create来代替fork.这跟规避策2一样都是比较实际的方法,值得推荐.

线程 fork之后

fork函数调用会创建子进程,子进程的地址空间是在调用fork时父进程地址空间的拷贝。因为子进程地址空间跟父进程一样,所以调用fork时,子进程继承了父进程中的所有互斥锁、读写锁和条件变量(包括它们的状态)。
但在多线程环境中,调用fork时,子进程中只有一个线程存在,这个线程是调用fork函数的那个线程,其他线程都没有被拷贝。
根据上述两点,子进程中的锁可能被不存在的线程所拥有,这样子进程将没法获取或释放这些锁。针对这个问题有一个解决办法,即在调用fork之前,线程先获取进程中所有锁,在调用fork后分别在父子进程中释放这些锁,从而可以重新利用这些资源。因为fork之前,当前线程拥有所有的锁,所以fork之后,当前线程继续存在,子进程可以安全的释放这些锁。
当然,在调用fork后,子进程马上调用exec,就无需考虑这些问题了,因为子进程地址空间被完全更换了。
函数pthread_atfork专门用来解决这种问题:
int pthread_atfork ( void (*prepare)(void), void (*parent)(void), void (*child)(void) );
pthread_atfork安装一些在fork调用时的回调函数。prepare函数将在fork创建子进程之前被调用,通常可以用来获取进程中的所有锁;parent在fork创建子进程后返回前在父进程中被调用,可以用来释放父进程中的锁;child在fork创建子进程后fork返回前在子进程中被调用,可以用来释放子进程中的锁。给这三个参数传递NULL,表示不调用该函数。
可以调用pthread_atfork多次注册多组回调函数,这时,回调函数调用的顺序规定如下:
①prepare函数调用顺序与它们的注册顺序相反;
②parent和child函数的调用顺序与注册顺序相同。

神奇的vfork

一段神奇的代码

在论坛里看到下面一段代码:
int createproc();
int main()
{
pid_t pid=createproc();
printf(“%d\n”, pid);
exit(0);
}
int createproc()
{
pid_t pid;
if(!(pid=vfork())) {
printf(“child proc:%d\n”, pid);
return pid;
}
else return -1;
}
输出结果:
child proc:0
0
child proc:0
Killed

感觉非常奇怪,为什么vfork以后,父子进程都走了“子进程”的分支呢?

什么是vfork?

      什么是vfork,网络上介绍它的文档很多,随便一搜就是一大堆。简单来说,vfork和fork完成了基本上相同的功能,把进程做了一次复制,变成两个进程。
在shell中,执行命令时,shell程序就是通过“复制”形成了父子进程。子进程生成后,执行exec系列函数,载入新的可执行文件,开始执行。
由于复制完成后,子进程马上就要载入新的程序来运行了,在此之前从父进程那里复制来的内存空间都不需要了。所以,“复制”过程中,复制内存空间是件费力不讨好的事情。
所以,fork有了“写时复制”技术。“复制”的时候内存并没有被复制,而是共享的。直到父子进程之一去写某块内存时,它才被复制。(内核先将这些内存设为只读,当它们被写时,CPU出现访存异常。内核捕捉异常,复制空间,并改属性为可写。)

   上面说到的内存空间是实际存储用户数据的空间,利用“写时复制”避免了干前面提到的那件费力不讨好的事情。
但是,“写时复制”其实还是有复制,进程的mm结构、页表都还是被复制了(“写时复制”也必须由这些信息来支撑。否则内核捕捉到CPU访存异常,怎么区分这是“写时复制”引起的,还是真正的越权访问呢?)。而vfork就把事情做绝了,所有有关于内存的东西都不复制了,父子进程的内存是完全共享的。但是这样一来又有问题了,虽然用户程序可以设计很多方法来避免父子进程间的访存冲突。但是关键的一点,父子进程共用着栈,这可不由用户程序控制的。一个进程进行了关于函数调用或返回的操作,则另一个进程的调用栈(实际上就是同一个栈)也被影响了。这样的程序没法运行下去。

      所以,vfork有个限制,子进程生成后,父进程在vfork中被内核挂起,直到子进程有了自己的内存空间(exec**)或退出(_exit)。并且,在此之前,子进程不能从调用vfork的函数中返回(同时,不能修改栈上变量、不能继续调用除_exit或exec系列之外的函数,否则父进程的数据可能被改写)。
尽管限制很多,但并不妨碍实现前面提到的关于shell程序的那个“需求”。

问题的思考

      说到这里,可以看出文章开头的那段代码是存在问题的了。子进程不但调用了printf,还从createproc函数中返回了。但是,子进程的违规为什么会使父进程走上“child proc”这条路呢?父进程在子进程退出前被阻塞在vfork里面,vfork的返回值是如何变成0的呢?前面一直在说vfork,其实它是两个东西,库(libc)函数vfork和系统调用vfork。用户程序调用的是库函数,而库函数再去调用系统调用。用户程序中几乎所有的系统调用都是通过库函数去调用的。因为不同体系结构下(甚至相同体系结构),系统调用的指令和参数传递规则都可能不同,这些细节被库函数隐藏了。

前面提到,父进程被挂起在vfork中,这是指的系统调用vfork。在系统调用中,进程使用的是内核栈(每个进程有着自己独有的内核栈)。此时,父进程在内核里面是安全的,随便子进程怎么违规。内核会保证系统调用vfork的完整性,系统调用的返回值也不会有问题(它是通过寄存器传回用户空间的,跟栈无关)。代码中从系统调用vfork返回后。

     代码中从系统调用vfork返回后子进程调用printf函数并且调用return返回到main最终执行exit(0),然后系统知道子进程要结束了,切换到父进程返回,但是此时由于保存在栈上的父进程返回地址由于子进程的违规(调用return)已经被改写,从而导致父进程返回地址的不确定性。具体到本例就是栈上的返回地址被改写了,从函数createproc返回,返回到printf(“child proc”)这句话去了。也就是说系统调用vfork总是确保父进程能够返回到此时保存在栈上的返回地址处。

再深入一点

     库函数vfork调用系统调用vfork后,库函数是怎样保证父进程正确返回到调用库函数的地址处呢?库函数vfork本身就是一个函数呀,库函数vfork调用系统调用vfork后此时,库函数vfork接着又返回了,按照一般的函数调用准则此时调用系统调用vfork时保存的返回地址已经被改写了。这时,程序的正确性又是如何保证的呢?

     关于函数调用,一般而言:调用前-调用者将需要传递的参数放到栈上;调用时-调用者使用call指令,该指令自动将返回地址入栈;调用后,在被调用的函数中,第一件事是做调用栈的调整,如createproc函数如是做:
08048487 <createproc>:
8048487:       55                      push   %ebp
8048488:       89 e5                 mov    %esp,%ebp
804848a:       83 ec 28            sub    $0x28,%esp
……
其中ESP是当前栈的指针,而EBP是上一层调用栈的指针。调用栈调整之前,EBP保存着上上一层栈的指针,这个值不能丢,需要放在栈上,以便函数返回时恢复。

每层调用都有自己的调用栈,“深”的调用不会影响到之前的调用栈。所以,vfork后子进程调用其他函数应该是没有问题的(但是可能会改写掉属于父进程的某些数据,造成逻辑上的错误),只要它不从调用vfork的函数中返回就行了。但是,库函数vfork本身却不是这样做的。在这个函数中没有使用栈上的内存空间,它没有去进行调用栈的切换,如:

000983f0 <__vfork>:

   0x00007ffff7355fa0 <+0>:     pop    %rdi
   0x00007ffff7355fa1 <+1>:     mov    %fs:0x2d4,%esi
   0x00007ffff7355fa9 <+9>:     mov    $0x80000000,%ecx
   0x00007ffff7355fae <+14>:    mov    %esi,%edx
   0x00007ffff7355fb0 <+16>:    neg    %edx
   0x00007ffff7355fb2 <+18>:    cmove  %ecx,%edx
   0x00007ffff7355fb5 <+21>:    mov    %edx,%fs:0x2d4
   0x00007ffff7355fbd <+29>:    mov    $0x3a,%eax
   0x00007ffff7355fc2 <+34>:    syscall
   0x00007ffff7355fc4 <+36>:    push   %rdi
   0x00007ffff7355fc5 <+37>:    test   %rax,%rax
   0x00007ffff7355fc8 <+40>:    je     0x7ffff7355fd2 <vfork+50>
   0x00007ffff7355fca <+42>:    mov    %esi,%fs:0x2d4
   0x00007ffff7355fd2 <+50>:    cmp    $0xfffff001,%eax
   0x00007ffff7355fd7 <+55>:    jae    0x7ffff7355fda <vfork+58>
   0x00007ffff7355fd9 <+57>:    retq
   0x00007ffff7355fda <+58>:    mov    0x2e0fc7(%rip),%rcx        # 0x7ffff7636fa8
   0x00007ffff7355fe1 <+65>:    xor    %edx,%edx
   0x00007ffff7355fe3 <+67>:    sub    %rax,%rdx
   0x00007ffff7355fe6 <+70>:    mov    %edx,%fs:(%rcx)
   0x00007ffff7355fe9 <+73>:    or     $0xffffffffffffffff,%rax
   0x00007ffff7355fed <+77>:    jmp    0x7ffff7355fd9 <vfork+57>

……

    所以父进程在库函数中运行时,不用担心栈上的数据已经被子进程修改(它根本不去使用栈上的数据)。然而call/ret指令却不得不使用栈(因为返回地址自动会被CPU放在栈上),如果子进程在vfork后调用其他函数,会使得父进程在进入库函数vfork时通过call指令在栈上留下的“返回地址”被擦掉。
事情的确是这样。于是库函数vfork为了解决这个问题,做了一些手脚,它并没有让栈上的“返回地址”一直留在栈上。注意上面的汇编代码,进入库函数vfork的第一条指令就是“pop %rdi”,把放在栈上的“返回地址”弹到了rdi中去,保存起来。然后在系统调用vfork返回后(int 0x80是用于系统调用的指令),再“push %ecx”,把“返回地址”放回去。

     库函数vfork和普通函数的调用开始处汇编不同,它没有压入栈中数据,栈中只保存返回地址(虽然在系统调用时使用过但是最终会还原),可以从vfork库函数的汇编代码中看到,库函数进行系统调用vfork时先做pop    %rdi把返回地址保存下来,当进行完syscall 从新写入返回地址,然后根据系统调用vfork的返回值做一些处理然后最终都会执行到retq 指令 返回保存的地址。所以父子进程在不破坏返回地址的时候都能返回到正确的地址,如果子进程违规则会导致不确定的返回地址!

UDP穿透NAT的原理

       NAT(Network Address Translators),网络地址转换:网络地址转换是在IP地址日益缺乏的情况下产生的,它的主要目的就是为了能够地址重用。NAT分为两大类,基本的NAT和NAPT(Network Address/Port Translator)。最开始NAT是运行在路由器上的一个功能模块。最先提出的是基本的NAT,它的产生基于如下事实:一个私有网络(域)中的节点中只有很少的节点需要与外网连接(呵呵,这是在上世纪90年代中期提出的)。那么这个子网中其实只有少数的节点需要全球唯一的IP地址,其他的节点的IP地址应该是可以重用的。

       因此,基本的NAT实现的功能很简单,在子网内使用一个保留的IP子网段,这些IP对外是不可见的。子网内只有少数一些IP地址可以对应到真正全球唯一的IP地址。如果这些节点需要访问外部网络,那么基本NAT就负责将这个节点的子网内IP转化为一个全球唯一的IP然后发送出去。(基本的NAT会改变IP包中的原IP地址,但是不会改变IP包中的端口)。关于基本的NAT可以参看RFC 1631。

       另外一种NAT叫做NAPT,从名称上我们也可以看得出,NAPT不但会改变经过这个NAT设备的IP数据报的IP地址,还会改变IP数据报的TCP/UDP端口。基本NAT的设备可能我们见的不多(呵呵,我没有见到过),NAPT才是我们真正讨论的主角。看下图:

Server S1

18.181.0.31:1235

|

^ Session 1 (A-S1) ^ |

| 18.181.0.31:1235 | |

v 155.99.25.11:62000 v |

|

NAT

155.99.25.11

|

^ Session 1 (A-S1) ^ |

| 18.181.0.31:1235 | |

v 10.0.0.1:1234 v |

|

Client A

10.0.0.1:1234

     有一个私有网络10.*.*.*,Client A是其中的一台计算机,这个网络的网关(一个NAT设备)的外网IP是155.99.25.11(应该还有一个内网的IP地址,比如10.0.0.10)。如果Client A中的某个进程(这个进程创建了一个UDP Socket,这个Socket绑定1234端口)想访问外网主机18.181.0.31的1235端口,那么当数据包通过NAT时会发生什么事情呢?

     首先NAT会改变这个数据包的原IP地址,改为155.99.25.11。接着NAT会为这个传输创建一个Session(Session是一个抽象的概念,如果是TCP,也许Session是由一个SYN包开始,以一个FIN包结束。而UDP呢,以这个IP的这个端口的第一个UDP开始,结束呢,呵呵,也许是几分钟,也许是几小时,这要看具体的实现了)并且给这个Session分配一个端口,比如62000,然后改变这个数据包的源端口为62000。所以本来是(10.0.0.1:1234->18.181.0.31:1235)的数据包到了互联网上变为了(155.99.25.11:62000->18.181.0.31:1235)。一旦NAT创建了一个Session后,NAT会记住62000端口对应的是10.0.0.1的1234端口,以后从18.181.0.31发送到62000端口的数据会被NAT自动的转发到10.0.0.1上。(注意:这里是说18.181.0.31发送到62000端口的数据会被转发,其他的IP发送到这个端口的数据将被NAT抛弃)这样Client A就与Server S1建立以了一个连接。呵呵,上面的基础知识可能很多人都知道了,那么下面是关键的部分了。

看看下面的情况:

Server S1 Server S2

18.181.0.31:1235 138.76.29.7:1235

| |

| |

+———————-+———————-+

|

^ Session 1 (A-S1) ^ | ^ Session 2 (A-S2) ^

| 18.181.0.31:1235 | | | 138.76.29.7:1235 |

v 155.99.25.11:62000 v | v 155.99.25.11:62000 v

|

Cone NAT

155.99.25.11

|

^ Session 1 (A-S1) ^ | ^ Session 2 (A-S2) ^

| 18.181.0.31:1235 | | | 138.76.29.7:1235 |

v 10.0.0.1:1234 v | v 10.0.0.1:1234 v

|

Client A

10.0.0.1:1234

      接上面的例子,如果Client A的原来那个Socket(绑定了1234端口的那个UDP Socket)又接着向另外一个Server S2发送了一个UDP包,那么这个UDP包在通过NAT时会怎么样呢?这时可能会有两种情况发生,一种是NAT再次创建一个Session,并且再次为这个Session分配一个端口号(比如:62001)。另外一种是NAT再次创建一个Session,但是不会新分配一个端口号,而是用原来分配的端口号62000。前一种NAT叫做Symmetric NAT,后一种叫做Cone NAT。我们期望我们的NAT是第二种,呵呵,如果你的NAT刚好是第一种,那么很可能会有很多P2P软件失灵。(可以庆幸的是,现在绝大多数的NAT属于后者,即Cone NAT)

      好了,我们看到,通过NAT,子网内的计算机向外连结是很容易的(NAT相当于透明的,子网内的和外网的计算机不用知道NAT的情况)。但是如果外部的计算机想访问子网内的计算机就比较困难了(而这正是P2P所需要的)。那么我们如果想从外部发送一个数据报给内网的计算机有什么办法呢?首先,我们必须在内网的NAT上打上一个”洞”(也就是前面我们说的在NAT上建立一个Session),这个洞不能由外部来打,只能由内网内的主机来打。而且这个洞是有方向的,比如从内部某台主机(比如:192.168.0.10)向外部的某个IP(比如:219.237.60.1)发送一个UDP包,那么就在这个内网的NAT设备上打了一个方向为219.237.60.1的”洞”,(这就是称为UDP Hole Punching的技术)以后219.237.60.1就可以通过这个洞与内网的192.168.0.10联系了。(但是其他的IP不能利用这个洞)。

       呵呵,现在该轮到我们的正题P2P了。有了上面的理论,实现两个内网的主机通讯就差最后一步了:那就是鸡生蛋还是蛋生鸡的问题了,两边都无法主动发出连接请求,谁也不知道谁的公网地址,那我们如何来打这个洞呢?我们需要一个中间人来联系这两个内网主机。

现在我们来看看一个P2P软件的流程,以下图为例:

Server S (219.237.60.1)

|

|

+———————-+———————-+

| |

NAT A (外网IP:202.187.45.3) NAT B (外网IP:187.34.1.56)

| (内网IP:192.168.0.1) | (内网IP:192.168.0.1)

| |

Client A (192.168.0.20:4000) Client B (192.168.0.10:40000)

      首先,Client A登录服务器,NAT A为这次的Session分配了一个端口60000,那么Server S收到的Client A的地址是202.187.45.3:60000,这就是Client A的外网地址了。同样,Client B登录Server S,NAT B给此次Session分配的端口是40000,那么Server S收到的B的地址是187.34.1.56:40000。此时,Client A与Client B都可以与Server S通信了。如果Client A此时想直接发送信息给Client B,那么他可以从Server S那儿获得B的公网地址187.34.1.56:40000,是不是Client A向这个地址发送信息Client B就能收到了呢?答案是不行,因为如果这样发送信息,NAT B会将这个信息丢弃(因为这样的信息是不请自来的,为了安全,大多数NAT都会执行丢弃动作)。现在我们需要的是在NAT B上打一个方向为202.187.45.3(即Client A的外网地址)的洞,那么Client A发送到187.34.1.56:40000的信息,Client B就能收到了。这个打洞命令由谁来发呢,呵呵,当然是Server S。

       总结一下这个过程:如果Client A想向Client B发送信息,那么Client A发送命令给Server S,请求Server S命令Client B向Client A方向打洞。呵呵,是不是很绕口,不过没关系,想一想就很清楚了,何况还有源代码呢(侯老师说过:在源代码面前没有秘密 8)),然后Client A就可以通过Client B的外网地址与Client B通信了。

       注意:以上过程只适合于Cone NAT的情况,如果是Symmetric NAT,那么当Client B向Client A打洞的端口已经重新分配了,Client A将无法知道这个端口(如果Symmetric NAT的端口是顺序分配的,那么我们或许可以猜测这个端口号,可是由于可能导致失败的因素太多,我们不推荐这种猜测端口的方法)。