将类作为线程启动
轻松将类作为线程启动
引言
本文介绍如何将一个类作为一个单独的线程启动。该项目是一个MFC对话框项目。它展示了如何将一个简单的C函数作为一个单独的线程启动。该函数接着实例化一个类并使其运行。在该项目中,主应用程序和线程之间有两种通信路径。第一种是公共内存区域。主应用程序和线程之间传递的数据使用此内存区域。第二种通信路径是通过由主应用程序创建并受其控制的事件。线程检测这些事件并根据它们采取行动。所有这一切都比初看起来要容易。
背景
启动一个单独的线程比我预期的要容易得多。困难出现在尝试将一个类用作线程时。我找到的最简单的文章在这里: Code Project 文章 但从运行一个独立的C函数作为线程转换到运行一个类是困难的。经过许多论坛帖子,我发现了一种非常简单的方法。我还没有找到讨论或展示这一概念的示例,因此,本文将介绍这个概念。
环境
Windows 7, Visual Studio 2008, MFC 对话框, C++
注意事项
此概念/方法的最终目的是创建可供非窗口应用程序使用的代码。主应用程序将使用此概念启动一个处理TCP/IP操作的线程。MFC的使用提供了显示带有控件的对话框的能力,这些控件驱动线程,并显示线程的性能和响应。MFC不是最终环境,只是开发和测试工具。随着项目的进行,该线程将执行TCP/IP类的**高**级控制。
本项目和对话框将进行扩展,直到我拥有一个能够连接客户端并发送数据的完整服务器应用程序。假数据由对话框生成,但数据仍然是数据。构成线程和TCP/IP代码的所有代码都保存在与解决方案分开的目录中,以便于重用。
再次说明
这只是一个测试工具,不是生产代码。自首次撰写本文以来,我已经充实了用于管理TCP/IP代码的类,创建了第二个类来维护低级TCP连接并发送数据。我还创建了第二个线程来处理非阻塞TCP/IP和重叠I/O所需的事件。我快完成事件测试了,很快就会添加发送数据的代码。
进一步警告
本示例不试图处理多线程的复杂性。编码者(您)必须警惕共享变量的读写权限和时机等方面的多种潜在错误和问题。在文章中处理这些问题会使文章过于复杂。我努力一次只处理一个主要步骤,而这对我来说是一个主要步骤。
存在的理由
对于经验丰富的Windows和Visual Studio程序员来说,这都是微不足道的事情。作为这个领域的新手,我发现收集此处提供的信息并将其组织在我的大脑和纸上的任务非常艰巨。因此,本文应运而生。
必备组件
读者应该能够创建带有按钮和文本控件的简单MFC对话框,以及创建类。
Using the Code
生成应用程序并启动它。对话框显示的是在事件创建、两个事件触发以及Timeout
过期两次之后的情况。
可以随时点击“**获取事件计数**”按钮。它显示线程检测到每个事件的次数。点击“**创建事件**”按钮来创建事件。句柄显示在按钮下方。显示它们没有必要,但我喜欢这样做。
点击“**创建简单线程**”以启动作为单独线程运行的类。按钮下方的文本显示线程创建的状态。在创建事件之前,此线程无法启动。存在一个五秒的超时值,它会显示在事件计数中。稍后将进行解释。
随意点击“**事件:开始**”和“**事件:停止**”来触发这些事件。点击“**获取事件计数**”按钮将显示线程检测到每个事件的次数。
“**事件:退出**”按钮会告诉线程退出,它将停止检测事件。可以根据需要重新启动线程。点击“**完成**”退出应用程序。(“完成”比“确定”是更好的标签。)
Common Structure.h
此包含文件声明了一组常量以及用于在主应用程序和线程之间传递数据的公共结构。此处省略了常量,但显示了结构。首先是WaitForMultipleObjects()
所需的事件数组。它是一个简单的事件句柄数组。参考上面的对话框图像,该数组包含值:276、284和288。MSDN描述易于理解。花几分钟仔细阅读。
稍后将提供填充该数组的代码。数组后面是一组计数器,一个用于三个显式声明的事件,另外两个用于可检测的附加事件。这些值由线程在检测到每个事件时递增,并由主应用程序读取以显示给用户。稍后将详细介绍这些变量。
struct st_common_data
{
HANDLE event_array[ EVENT_ARRAY_SIZE ];
unsigned int count_of_start_events;
unsigned int count_of_stop_events;
unsigned int count_of_exit_events;
unsigned int count_of_timeout_events;
unsigned int count_of_unexpected_return_value;
};
C_TCP_Thread.h
这是作为线程启动的类。它最终将处理TCP/IP通信,因此得名。此处删除了所有注释和类似内容,只保留了精髓。此演示所需内容不多。
#include "Common_Structure.h"
unsigned int __stdcall Start_As_Thread( void * p_void_pointer );
class C_TCP_Thread
{
public:
C_TCP_Thread( void * ptr_common_data );
~C_TCP_Thread(void);
void Main_TCP_Thread( );
private:
st_common_data * mp_common_data;
};
首先是Start_As_Thread()
。它被声明在类范围之外,使其成为一个独立的函数。它返回一个无符号int
。__stdcall
是给编译器的指令,指定该函数如何被调用。它由_beginthreadex()
需要。该函数被赋予一个void
指针。指向公共结构的指针将作为该void
指针传递。稍后将详细介绍。
构造函数将在类实例化时接收相同的指针。没有默认构造函数。类必须使用指针创建。
方法Main_TCP_Thread
被命名为与用于启动所有简单程序的Main()
相似。它运行直到线程生命周期结束。
在private
部分有一个指向公共结构的指针。该指针的值通过Start_As_Thread()
传入。
C_TCP_Thread.cpp
这是作为一个单独线程运行的类。它将分段呈现,首先是那个独立的C函数:Start_As_Thread()
。
unsigned int __stdcall Start_As_Thread( void * p_void_pointer )
{
C_TCP_Thread * mp_C_TCP_Thread;
mp_C_TCP_Thread = new C_TCP_Thread( p_void_pointer );
mp_C_TCP_Thread->Main_TCP_Thread();
delete mp_C_TCP_Thread;
_endthreadex( 0 );
return 0;
}
请注意,它不包含C_TCP_Thread::
这个短语。它被声明并定义在类范围之外。这就是使线程易于启动的原因。
前两行声明了一个指向该类的指针,然后实例化它。构造函数通过_beginthreadex()
接收传入的void指针;这是公共内存结构的地址。主应用程序和线程都有该地址。
调用了方法Main_TCP_Thread()
。它运行直到线程生命周期结束。当它返回时,类会被立即删除。这有助于防止内存泄漏。线程使用endthreadex(0);
结束自身。
这里有两个有趣的点。应该返回什么值?短暂搜索Google没有找到建议。这重要吗?
在这里放置_endthreadex(0)
简化了主应用程序的代码。线程自行结束,主应用程序无需负责。这里的问题是:在调试器中,线程在该语句处结束,因此return 0;
从未执行。根据MSDN,线程应该返回一个值。没有return 0;
编译器会抱怨。也许,严格来说,这应该以不同的方式完成。它确实会使主应用程序的线程句柄悬空,因此程序员必须处理它,或者明确忽略它。线程句柄在启动线程时被捕获在主应用程序中,并且可以作为紧急方法来终止线程。我选择忽略它。
类构造函数
C_TCP_Thread::C_TCP_Thread( void * ptr_common_data )
{
mp_common_data = ( st_common_data * ) ptr_common_data;
}
它唯一的作用是将void
指针转换回指向公共数据的指针并保存它。
Main_TCP_Thread()
这是线程的主方法。这是做所有事情的方法。它运行直到线程完成其工作。当它退出时,线程就结束了。
void C_TCP_Thread::Main_TCP_Thread( )
{
bool done = false;
DWORD event_triggered = 0;
while( !done )
{ // This is the key Windows API call.
// Wait here until any of the events are triggered,
// to include the timeout event.
event_triggered = WaitForMultipleObjects(
EVENT_ARRAY_SIZE,
mp_common_data->event_array,
WAIT_FOR_ANY_EVENT,
WAIT_TIMEOUT_VALUE );
// This only does enough to show that each event is detected
// separate from the others.
// Note that these are only starter/demo events.
// When code is added for TCP/IP operations those events
// will be added along with an FSM to manage the TCP/IP
// operations. (Finite State Machine)
switch( event_triggered )
{
// Check out these constants in Common_Structure.h.
case EVENT_RETURN_START_RUNNING:
{
mp_common_data->count_of_start_events ++;
break;
}
case EVENT_RETURN_STOP_RUNNING:
{
mp_common_data->count_of_stop_events ++;
break;
}
case EVENT_RETURN_THREAD_EXIT:
{
mp_common_data->count_of_exit_events ++;
done = true;
break;
}
case WAIT_TIMEOUT:
{
mp_common_data->count_of_timeout_events ++;
break;
}
default:
{
mp_common_data->count_of_unexpected_return_value ++;
break;
}
}
} // end of: while( !done )
}
代码假定指针已设置并且事件已创建。这是一个简单的while
循环,一直运行直到检测到退出线程事件。
此方法关键在于调用WaitForMultipleObjects()
。与其用一些无用的数字(如3、0和5000)作为参数调用此API,不如使用相对自解释的常量。WAIT_FOR_ANY_EVENT
是一个设置为FALSE
的BOOL
,表示触发的任何事件都会导致调用返回,代码将继续/恢复运行。当设置为TRUE
时,所有事件都必须触发后代码才会继续。WaitForMultipleObjects()
的返回值是一个特殊编码的值。这是一个展示*Common_Structure.h*更多内容的好地方。
const DWORD EVENT_RETURN_START_RUNNING = WAIT_OBJECT_0;
const DWORD EVENT_RETURN_STOP_RUNNING = EVENT_RETURN_START_RUNNING + 1;
const DWORD EVENT_RETURN_THREAD_EXIT = EVENT_RETURN_STOP_RUNNING + 1;
WAIT_OBJECT_0
是微软定义的常量。当返回该值时,表示检测到数组中的第一个事件。定义了一个命名良好的常量并赋予了该值。对于每个后续事件,值会增加一。因此,这就是定义这些常量的格式。这意味着程序员必须小心跟踪这些事件。
我们不应假定WAIT_OBJECT_0
的值为零,或者无论它具有什么值,它都不会改变。因此,它不能用作事件数组的索引。巧妙的程序员可以设计方法来避免常量列表,但为每个数组拥有命名良好的常量有显著的好处。
同样来自Common_Structure.h
const unsigned int EVENT_START_RUNNING = 0;
const unsigned int EVENT_STOP_RUNNING = EVENT_START_RUNNING + 1;
const unsigned int EVENT_THREAD_EXIT = EVENT_STOP_RUNNING + 1;
const unsigned int EVENT_ARRAY_SIZE = EVENT_THREAD_EXIT + 1;
这些是用于索引事件句柄数组的常量。这两个常量集的协调使主应用程序和线程以同步方式使用事件句柄。阅读代码时,这些常量很容易确定代码的确切作用。当我为异步TCP/IP操作添加更多代码时,这会变得更加复杂。现在保持简单将使这项任务更容易。
希望switch
语句的操作是显而易见的。每次检测到事件时,它都会被计数。它没有做任何有用的事情,但确实演示了这些概念如何协同工作以创建一个功能性线程。如果您需要更多解释,请发表评论。
回到代码...
WaitForMultipleObjects()
的调用位置很重要,每个事件都在重置状态下创建也很重要。线程启动然后立即等待主应用程序的事件。它实际上处于挂起状态。或者更准确地说,是阻塞状态。当我编写TCP/IP处理程序代码时,我可能希望线程在打开端口监听客户端之前等待一段时间。我也可能希望能够暂停TCP操作一段时间。此演示项目中的事件将有助于此。
OnBnClickedBtnCreateEvents()
转移到对话框,我们现在来看一些控制操作的按钮代码。
void CStart_New_Thread_2Dlg::OnBnClickedBtnCreateEvents()
{
const unsigned int STRING_LENGTH = 16;
char event_number[ STRING_LENGTH ];
LPSECURITY_ATTRIBUTES NO_SECURITY_ATTRIBUTES = NULL;
const bool EVENT_TYPE_MANUAL_RESET = TRUE;
const bool EVENT_TYPE_AUTO_RESET = FALSE;
const bool INITIALIZE_AS_TRIGGERED = TRUE;
const bool INITIALIZE_AS_RESET = FALSE;
LPCTSTR NO_EVENT_NAME = NULL;
m_common_data.event_array[ EVENT_START_RUNNING ] =
CreateEvent( NO_SECURITY_ATTRIBUTES,
EVENT_TYPE_AUTO_RESET,
INITIALIZE_AS_RESET,
NO_EVENT_NAME );
m_common_data.event_array[ EVENT_STOP_RUNNING ] =
CreateEvent( NO_SECURITY_ATTRIBUTES,
EVENT_TYPE_AUTO_RESET,
INITIALIZE_AS_RESET,
NO_EVENT_NAME );
m_common_data.event_array[ EVENT_THREAD_EXIT ] =
CreateEvent( NO_SECURITY_ATTRIBUTES,
EVENT_TYPE_AUTO_RESET,
INITIALIZE_AS_RESET,
NO_EVENT_NAME );
sprintf_s( event_number,
STRING_LENGTH,
"%d",
m_common_data.event_array[ EVENT_START_RUNNING ] );
m_start_event_handle.SetWindowTextA( event_number );
sprintf_s( event_number,
STRING_LENGTH,
"%d",
m_common_data.event_array[ EVENT_STOP_RUNNING ] );
m_stop_event_handle.SetWindowTextA( event_number );
sprintf_s( event_number,
STRING_LENGTH,
"%d",
m_common_data.event_array[ EVENT_THREAD_EXIT ] );
m_exit_event_handle.SetWindowTextA( event_number );
}
此按钮的目的从名称即可看出。请记住,创建事件与触发/设置事件不同。这只是说:“嘿,操作系统,我需要一个事件,请创建它并给我一个句柄。”这做了三次,每个句柄都使用前面提到的常量放入事件数组中。一旦我们有了这些句柄,我们就可以设置事件并检测它们。注意使用了自动重置。这意味着事件会自动重置,或者如果您愿意,可以重新激活。请务必阅读MSDN关于此函数和事件的页面。
几乎不用说,命名良好的常量使代码非常易于阅读和自解释。最后几行代码会在对话框中显示句柄。这没有必要,我只是喜欢看到它们。
设置事件
关于事件,这是设置事件的代码。
void CStart_New_Thread_2Dlg::OnBnClickedBtnEventExit()
{
BOOL status = SetEvent( m_common_data.event_array[ EVENT_THREAD_EXIT ]);
}
这比我预期的要容易得多。只有三个事件,因此有三个按钮,其他两个看起来几乎都一样。只有数组索引会改变。
创建线程
现在我们准备创建线程,这是项目和文章的全部目的。代码块之后的文本继续。
void CStart_New_Thread_2Dlg::OnBnClicked_Create_Simple_Thread()
{
const int DISPLAY_MAX = 50;
char t_string[ DISPLAY_MAX ];
// Do not start the thread until the events have been created.
if( m_common_data.event_array[ EVENT_START_RUNNING ] == 0 )
{
m_thread_start_status.SetWindowTextA( "Create Events First" );
return;
}
// These few lines start the free standing procedure as a thread.
// That function instantiates the class and calls its main method.
void * NO_SECURITY_ATTRIBUTES = NULL;
const unsigned USE_DEFAULT_STACK_SIZE = 0;
const unsigned CREATE_IN_RUN_STATE = 0;
uintptr_t thread_pointer;
thread_pointer = _beginthreadex( NO_SECURITY_ATTRIBUTES,
USE_DEFAULT_STACK_SIZE,
&Start_As_Thread,
&m_common_data,
CREATE_IN_RUN_STATE,
&m_thread_address);
if( thread_pointer == 0 )
{
m_return_value = _get_errno( &m_thread_start_error );
if( m_return_value == 0 )
{
sprintf_s( t_string, DISPLAY_MAX, "Create Fail, error %d", m_thread_start_error );
}
else
{
sprintf_s( t_string, DISPLAY_MAX,
"Create Fail, get_errno fail, returned %d", m_return_value );
}
}
else
{
sprintf_s( t_string, DISPLAY_MAX, "Thread Created" );
}
m_thread_start_status.SetWindowTextA( t_string );
}
事件数组在OnInitDialog();
中初始化为全零。我忘记在启动线程之前创建事件,因此出现了变量计数值数千的情况。
mp_common_data->count_of_unexpected_return_value
当点击“**获取事件计数**”按钮时,它就显示出来了。因此,我添加了一些代码来告知用户,并拒绝启动线程,直到事件被创建。我至少有一个洁癖者抱怨一个函数应该只有一个退出点。我认为,当需要快速干净地退出时,返回比用if
块来掩盖其余代码更好。
接下来是一些,希望是自解释的常量,然后是启动线程的代码。第三个参数是整个文章的目的。记住,Start_As_Thread()
函数是一个独立的函数,也就是说,它不属于任何类。因此,它的地址可以直接放入此调用中。我觉得这比本文开头引用的文章中使用的方法要简单得多。
编码策略
此主应用程序及其线程之间的通信非常简单。主应用程序只写入事件数组一次。线程会反复读取该数组,但从不写入。线程写入记录事件检测次数的计数器。它从不读取这些计数器中的数据。(是的,是的。严格来说,它必须读取它才能获取要增量的值,但它从未在代码中使用该值,因此实际上只写入该值。)
当您将此类扩展为执行更多操作时,我将就此公共区域提供以下建议。主应用程序可以写入,但永远不能读取一组变量。线程读取它们,但从不写入。对于公共区域中的其余变量,情况将相反,即线程写入但从不读取,而主应用程序只读取。每个公共变量仅用于单向通信,绝不用于相反方向。
此外,如果公共结构分为两部分,也会有所帮助。由主应用程序写入的所有项在前,然后是线程写入的所有项,或者反之亦然。最好用前缀命名它们,表示编写者。然后,在阅读代码时,很容易看出哪个是什么。
这肯定不是关于进程间通信的全面建议。但使用时,会很有帮助。该主题值得,并且已经引起了大量的讨论。
更新
注意
本文的评论中有人对公共数据区域缺乏同步和控制表示担忧。此更新解决了这些问题。
除了上面讨论的事件之外,本项目还有两个数据路径用于在主应用程序和线程之间通信。
重要的是要注意,本项目很简单。从主应用程序到线程的数据路径包含三个整数,即事件的句柄。它们在线程启动之前写入一次,之后再也不写入。考虑到这一限制,不需要任何类型的互斥体。
从线程到主应用程序的数据路径是五个整数,由线程写入,由主应用程序读取。同样,写/读周期是单向的。线程一次写入简单的标量,主应用程序读取这些值。主应用程序不做出任何决策,也不基于这些整数计算任何值。它们仅用于显示。此外,在此示例中,在计算机负载较轻或空闲时,线程在用户能够将鼠标移到“获取事件计数”按钮上之前,总是会完成其更新。
强烈建议新手对互斥体、信号量和其他控制和共享公共资源的方法进行认真研究和阅读。
我认为这就是启动一个类作为线程所需的所有内容。我希望它对您有所帮助。
关注点
经过长时间的犹豫,启动一个单独的线程比预期的要容易得多。很多时候,事情比看起来要简单得多。有效地利用这个工具需要大量的仔细和实践。但如果您曾经想过可能需要启动一个线程,不妨试试,并将其添加到您的工具箱中。
历史
- V01: 2013年12月