扫一扫
关注微信公众号

在多线程应用程序中进行日志记录
2008-07-11   IBM

没有任何软件是完全没有错误的,在程序的运行期间,应用程序用户可能会碰到意想不到的结果。要分析并找出导致这些问题的原因,程序员所广泛使用的一种方法就是日志记录。在本文中,您将了解如何使用循环缓冲区通过内存操作(而不是文件操作)高效地进行日志记录。为该缓冲区选择合适的大小,从而确保转储相关的消息,这将在调试时很有帮助。

引言

“如果有两种方式可以编写出没有错误的程序,那么只有第三种方式是有效的。” —Alan J. Perlis

在关键的计算机应用程序的生存期中,日志记录是一件非常重要的活动,特别是当故障的症状并不十分明显时。日志记录提供了故障前应用程序状态的详细信息,如变量的值、函数的返回值等等。在一段时间的运行过程中,将不断地产生大量的跟踪数据,并持续地将其写入到磁盘上的文本文件中。要进行有效的日志记录,需要使用大量的磁盘空间,并且在多线程环境中,所需的磁盘空间会成倍地增加,因为大量的线程都在记录它们的跟踪信息。

使用常规文件进行日志记录的两个主要问题是:硬盘空间的可用性,以及在对一个文件写入数据时磁盘 I/O 的速度较慢。持续地对磁盘进行写入操作可能会极大地降低程序的性能,导致其运行速度缓慢。通常,可以通过使用日志轮换策略来解决空间问题,将日志保存在几个文件中,当这些文件大小达到某个预定义的字节数时,对它们进行截断和覆盖。

要克服空间问题并实现磁盘 I/O 的最小化,某些程序可以将它们的跟踪数据记录在内存中,仅当请求时才转储这些数据。这个循环的、内存中的缓冲区称为循环缓冲区。本文讨论了循环缓冲区的一些常见实现,并对多线程程序中循环缓冲区的启用机制提出了一些观点。

循环缓冲区

循环缓冲区是一种用于应用程序的日志记录技术,它可以将相关的数据保存在内存中,而不是每次都将其写入到磁盘上的文件中。在需要的时候(比如当用户请求将内存数据转储到文件中时、程序检测到一个错误时,或者由于非法的操作或者接收到的信号而引起程序崩溃时)可以将内存中的数据转储到磁盘。循环缓冲区日志记录由一个固定大小的内存缓冲区构成,进程使用这个内存缓冲区进行日志记录。顾名思义,该缓冲区采用循环的方式进行实现。当该缓冲区填满了数据时,无需为新的数据分配更多的内存,而是从缓冲区开始的位置对其进行写操作,因此将覆盖以前的内容。请参见图 1 中的示例。


图 1. 对循环缓冲区进行写操作
对循环缓冲区进行写操作

图 1 显示了将两个条目写入到循环缓冲区后该缓冲区的状态。在写入了第一个日志条目(用蓝色表示)之后,当该进程尝试写入第二个条目(用红色表示)时,该缓冲区中已经没有足够的剩余空间。该进程写入数据,一直到达缓冲区的末尾,然后将剩余的数据复制到缓冲区的开始位置,覆盖以前的日志条目。

请参见参考资料中提供的循环缓冲区实现的示例。
通过保存一个读指针,可以实现对循环缓冲区的读操作;相应地移动读指针和写指针,以确保在进行读操作期间,读指针不会越过写指针。为了提高效率,一些应用程序可以将原始数据(而不是经过格式化的数据)保存到该缓冲区。在这种情况下需要一个解析器,该解析器可以根据这些内存转储生成有意义的日志消息。

循环缓冲区的优点

当您可以简单地对一个文件进行写入操作时,为什么要使用循环缓冲区呢?因为您覆盖了循环缓冲区中以前的内容,所以在完成该操作后,您将丢失以前的数据。与传统的文件日志记录机制相比,循环缓冲区提供了下列优势。
  • 速度快。与磁盘的 I/O 操作相比,内存的写操作要快得多。仅当需要的时候才刷新数据。
  • 持续的日志记录可能会填满系统中的空间,从而导致其他程序也耗尽空间并且执行失败。在这样的情况下,您有两种选择,要么手动地删除日志信息,要么实现日志轮换策略。
  • 一旦您启用了日志记录,无论您是否需要它,该进程都将持续地填充硬盘上的空间。
  • 有时,您仅仅需要程序崩溃之前的相关数据,而不是该进程完整的历史数据。
  • 有一些常见的调试函数,如 printf、write 等,可能会在多线程应用程序的情况下更改一个程序的行为,使得它们难以调试。使用这些函数会导致应用程序隐藏某些平时可能表现出来的错误。这些函数都是可撤销点,并且可能导致在线程环境中产生一个该程序并不期望的挂起信号。清单 1 中假定的示例(伪代码)和下面的清单 2 更清楚地说明了这一点。

清单 1. 没有启用调试的代码
                
pthread_setcanceltype(PTHREAD_CANCEL_DEFERRED,NULL);
/* I should not be cancelled in the below section */
var=5;
#ifdef DEBUG
 write(fd,"Value of var = 5n",17);
#endif
var=pow(var,2);

/* I can be cancelled now */
pthread_testcancel();



清单 2. 启用了调试的代码
                
pthread_setcanceltype(PTHREAD_CANCEL_DEFERRED,NULL);
/* I should not be cancelled in the below section */
var=5;
#ifdef DEBUG
write(fd,"Value of var = 5n",17);  <======== Cancel delivered here!
#endif
var=pow(var,2);

/* I can be cancelled now */
pthread_testcancel();

 

在多线程程序中使用循环缓冲区

有时,当其他传统的日志记录方法失败时,可以使用循环缓冲区日志记录。这个部分介绍了在多线程应用程序中使用循环缓冲区启用日志记录时需要考虑的一些重要方面。

在访问一个公共的资源时,同步 始终是多线程程序不可缺少的部分,日志记录也不例外。因为每个线程都试图对全局空间进行写操作,所以必须确保它们同步地写入内存,否则消息就会遭到破坏。通常,每个线程在写入缓冲区之前都持有一个锁,在完成操作时释放该锁。您可以下载一个使用锁对内存进行写操作的循环缓冲区示例。

这种方法具有以下的缺点:如果您的应用程序中包含几个线程,并且每个线程都在进行详细地日志记录,那么该进程的整体性能将会受到影响,因为这些线程将在获得和释放锁上花费了大部分的时间。

通过使得每个线程将数据写入到它自己的内存块,就可以完全避免同步问题。当收到来自用户的转储数据的请求时,每个线程获得一个锁,并将其转储到中心位置。因为仅在将数据刷新到磁盘时获得锁,所以性能并不会受到很大的影响。在这样的情况下,您可能需要一个附加的程序对日志信息进行排序(按照线程 ID 或者时间戳的顺序),以便对其进行分析。您还可以选择仅在内存中写入消息代码,而不是完整的格式化消息,稍后使用一个外部的实用工具解析转储数据,将消息代码转换为有意义的文本。

另一种避免同步问题的方法是,分配一个很大的全局内存块,并将其划分为较小的槽位,其中每个槽位都可由一个线程用来进行日志记录。每个线程只能够读写它自己的槽位,而不是整个缓冲区。当每个线程第一次尝试写入数据时,它会尝试寻找一个空的内存槽位,并将其标记为忙碌。当线程获得了一个特定的槽位时,可以将跟踪槽位使用情况的位图中相应的位设置为 1,当该线程退出时,重新将这个位设置为 0。同时需要维护当前使用的槽位编号的全局列表,以及正在使用它的线程的线程信息。

要避免出现这样的情况,即一个线程已经死亡,但是却没有将其槽位在位图中对应的位设置为 0,您需要一个垃圾收集器线程,它遍历全局列表,并根据线程 ID 以固定的时间间隔进行轮询。它负责释放槽位并修改全局列表。请参见下面的清单 3 中给出的示例。


清单 3. 垃圾收集器线程的示例伪代码
                
void Check_and_free(List *ptr){
    int slotno,ret_val;
    LockList();
    while(ptr){
    if ( ((ret_val = pthread_kill(ptr->thread_id,0)) == ESRCH) ){
       /* Thread has died */
       slotno=ptr->slotno;
       Free_slot(ptr->thread_id);
       Mark_bitmap_free(slotno);
      }
      ptr=ptr->next;
    }
    UnlockList();
    return ;
}
    

通常线程 ID 会得到重用,因此可能会出现这样的情况,即一个线程已经死亡,却没有释放相应的槽位,并在垃圾收集器释放该槽位之前,再次使用了这个线程 ID 并为其分配一个新的槽位。对于新的线程来说,检查全局列表并且重用相同的槽位(如果以前的实例使用了它的话),这是非常重要的。因为垃圾收集器线程和写入者线程可能同时尝试修改全局列表,所以同样也需要使用某种锁定机制。

当用户对进程发出转储循环缓冲区数据的信号时,处理该信号的线程将不允许其他的线程再更改缓冲区的内容,并将所用槽位中的内容转储到文件中。清单 4清单 5 显示了对循环缓冲区写入数据并且将其内容转储到文件的示例。一旦接收到了转储数据的信号,将使用 is_dumping 全局变量禁止其他的线程更改该缓冲区的内容。


清单 4. 对循环缓冲区槽位 “I” 进行写操作的示例伪代码
                
void Write_to_buffer(char *msg){
     read_atomically(&is_dumping);
     if(!is_dumping)
       memcpy(slot[i]->ptr,msg,strlen(msg));
     return;
}
 


清单 5. 转储循环缓冲区数据的示例伪代码
                
void Dump_data(int fd){
     change_atomically_to_true(&is_dumping);
     for i in each_used_slot {
        write_slot_data_to_file(fd,slot[i]);
      }
     change_atomically_to_false(&is_dumping);
     return;
}
 

结束语

通过使用内存操作代替文件操作,循环缓冲区使得日志记录的效率更高。为缓冲区选择合适的大小以确保转储相关的消息,这在进行程序的事后检查分析时是很有帮助的。对于那些不断地进行日志记录的程序来说,循环缓冲区是一种理想的解决方案,并且在调试的过程中,您并不需要该程序完整的历史信息。本文介绍了在多线程程序中实现循环缓冲区的方法和一些需要注意的内容。

热词搜索:

上一篇:Linux系统安全之程序日志的使用概述
下一篇:Windows 到 Linux 之旅: Linux 日志

分享到: 收藏