404
页面不存在
这里什么也没有
From 91961079d632ba37760fd6e3321b68a24559d84f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 11:08:22 +0000 Subject: [PATCH] Deploy to GitHub pages --- 404.html | 40 + SUMMARY.html | 40 + ...\350\247\243\346\236\220.html-EFWzQ7p9.js" | 54 ++ ...\346\246\202\345\277\265.html-BKokb-lV.js" | 1 + ...\350\247\243\346\236\220.html-1I_9i2hp.js" | 77 ++ ...\347\272\277\347\250\213.html-CuDgfpeg.js" | 381 ++++++++ ...\350\247\243\346\236\220.html-Cuvtvbpc.js" | 339 +++++++ ...\346\225\260\346\215\256.html-BLgsE6xw.js" | 396 ++++++++ ...\346\223\215\344\275\234.html-rJexZaMy.js" | 868 +++++++++++++++++ ...\347\250\213\346\261\240.html-B8ceqDbR.js" | 220 +++++ ...\346\223\215\344\275\234.html-QZXnSagT.js" | 258 +++++ ...\345\215\217\347\250\213.html-f5RY1G-q.js" | 1 + assets/404.html-C-h4i3I8.js | 1 + assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 | Bin 0 -> 28076 bytes assets/KaTeX_AMS-Regular-DMm9YOAa.woff | Bin 0 -> 33516 bytes assets/KaTeX_AMS-Regular-DRggAlZN.ttf | Bin 0 -> 63632 bytes assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf | Bin 0 -> 12368 bytes assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff | Bin 0 -> 7716 bytes assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 | Bin 0 -> 6912 bytes .../KaTeX_Caligraphic-Regular-CTRA-rTL.woff | Bin 0 -> 7656 bytes .../KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 | Bin 0 -> 6908 bytes assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf | Bin 0 -> 12344 bytes assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf | Bin 0 -> 19584 bytes assets/KaTeX_Fraktur-Bold-BsDP51OF.woff | Bin 0 -> 13296 bytes assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 | Bin 0 -> 11348 bytes assets/KaTeX_Fraktur-Regular-CB_wures.ttf | Bin 0 -> 19572 bytes assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 | Bin 0 -> 11316 bytes assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff | Bin 0 -> 13208 bytes assets/KaTeX_Main-Bold-Cx986IdX.woff2 | Bin 0 -> 25324 bytes assets/KaTeX_Main-Bold-Jm3AIy58.woff | Bin 0 -> 29912 bytes assets/KaTeX_Main-Bold-waoOVXN0.ttf | Bin 0 -> 51336 bytes assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 | Bin 0 -> 16780 bytes assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf | Bin 0 -> 32968 bytes assets/KaTeX_Main-BoldItalic-SpSLRI95.woff | Bin 0 -> 19412 bytes assets/KaTeX_Main-Italic-3WenGoN9.ttf | Bin 0 -> 33580 bytes assets/KaTeX_Main-Italic-BMLOBm91.woff | Bin 0 -> 19676 bytes assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 | Bin 0 -> 16988 bytes assets/KaTeX_Main-Regular-B22Nviop.woff2 | Bin 0 -> 26272 bytes assets/KaTeX_Main-Regular-Dr94JaBh.woff | Bin 0 -> 30772 bytes assets/KaTeX_Main-Regular-ypZvNtVU.ttf | Bin 0 -> 53580 bytes assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf | Bin 0 -> 31196 bytes assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 | Bin 0 -> 16400 bytes assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff | Bin 0 -> 18668 bytes assets/KaTeX_Math-Italic-DA0__PXp.woff | Bin 0 -> 18748 bytes assets/KaTeX_Math-Italic-flOr_0UB.ttf | Bin 0 -> 31308 bytes assets/KaTeX_Math-Italic-t53AETM-.woff2 | Bin 0 -> 16440 bytes assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf | Bin 0 -> 24504 bytes assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 | Bin 0 -> 12216 bytes assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff | Bin 0 -> 14408 bytes assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 | Bin 0 -> 12028 bytes assets/KaTeX_SansSerif-Italic-DN2j7dab.woff | Bin 0 -> 14112 bytes assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf | Bin 0 -> 22364 bytes assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf | Bin 0 -> 19436 bytes assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff | Bin 0 -> 12316 bytes assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 | Bin 0 -> 10344 bytes assets/KaTeX_Script-Regular-C5JkGWo-.ttf | Bin 0 -> 16648 bytes assets/KaTeX_Script-Regular-D3wIWfF6.woff2 | Bin 0 -> 9644 bytes assets/KaTeX_Script-Regular-D5yQViql.woff | Bin 0 -> 10588 bytes assets/KaTeX_Size1-Regular-C195tn64.woff | Bin 0 -> 6496 bytes assets/KaTeX_Size1-Regular-Dbsnue_I.ttf | Bin 0 -> 12228 bytes assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 | Bin 0 -> 5468 bytes assets/KaTeX_Size2-Regular-B7gKUWhC.ttf | Bin 0 -> 11508 bytes assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 | Bin 0 -> 5208 bytes assets/KaTeX_Size2-Regular-oD1tc_U0.woff | Bin 0 -> 6188 bytes assets/KaTeX_Size3-Regular-CTq5MqoE.woff | Bin 0 -> 4420 bytes assets/KaTeX_Size3-Regular-DgpXs0kz.ttf | Bin 0 -> 7588 bytes assets/KaTeX_Size4-Regular-BF-4gkZK.woff | Bin 0 -> 5980 bytes assets/KaTeX_Size4-Regular-DWFBv043.ttf | Bin 0 -> 10364 bytes assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 | Bin 0 -> 4928 bytes assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff | Bin 0 -> 16028 bytes .../KaTeX_Typewriter-Regular-CO6r4hn1.woff2 | Bin 0 -> 13568 bytes assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf | Bin 0 -> 27556 bytes assets/SUMMARY.html-BnXBmIpJ.js | 1 + assets/SearchResult-jru87sjv.js | 1 + assets/Tableau10-B-NsZVaP.js | 1 + assets/app-Ca-A-iaw.js | 37 + assets/arc-BDtf7cdN.js | 1 + assets/array-BKyUJesY.js | 1 + assets/auto-Cl2ltNcc.js | 18 + assets/blockDiagram-9f4a6865-CCynznDb.js | 118 +++ assets/c4Diagram-ae766693-Cwq3C8UU.js | 10 + assets/channel-BQsRZjn0.js | 1 + assets/classDiagram-fb54d2a0-B6KDNP-K.js | 2 + assets/classDiagram-v2-a2b738ad-BzrN1-EV.js | 2 + assets/clone-CH2yQtrO.js | 1 + assets/createText-ca0c5216-bYdpW0Zp.js | 7 + assets/edges-066a5561-CYMTSPpJ.js | 4 + assets/erDiagram-09d1c15f-kySp2mgy.js | 51 + assets/flowDb-c1833063-Dab_m0tb.js | 10 + assets/flowDiagram-b222e15a-DarGeLKH.js | 4 + assets/flowDiagram-v2-13329dc7-B5Y-44JY.js | 1 + assets/flowchart-CTwbLKUk.js | 11 + ...wchart-elk-definition-ae0efee6-D2n0UGGT.js | 139 +++ assets/ganttDiagram-b62c793e-DZKZndxq.js | 257 +++++ assets/gitGraphDiagram-942e62fe-B3EvEA8b.js | 70 ++ assets/graph-DkiqFSuU.js | 1 + assets/index-01f381cb-wllKWk9M.js | 1 + assets/index-AN989yVn.js | 61 ++ assets/index.html-BFILG7Me.js | 1 + assets/index.html-BHEgHCm0.js | 1 + assets/index.html-BNiJ94F3.js | 1 + assets/index.html-BV8xYf6R.js | 1 + assets/index.html-C6Z_qkjf.js | 16 + assets/index.html-CumviD6U.js | 1 + assets/infoDiagram-94cd232f-CmPJgRj5.js | 7 + assets/init-Gi6I4Gst.js | 1 + assets/journeyDiagram-6625b456-C95pQKsn.js | 139 +++ assets/katex-CvgdMzdh.js | 261 +++++ assets/layout-BZLwH4ql.js | 1 + assets/line-DS2p6aKt.js | 1 + assets/linear-BnGacGIF.js | 1 + assets/mermaid.core-Bc1j4tiv.js | 91 ++ .../mindmap-definition-307c710a-DdlwPr3Y.js | 110 +++ assets/ordinal-Cboi1Yqb.js | 1 + assets/path-CbwjOpE9.js | 1 + assets/photoswipe.esm-GXRgw7eJ.js | 4 + assets/pieDiagram-bb1d19e5-BQhQYyps.js | 35 + assets/plugin-vue_export-helper-DlAUqK2U.js | 1 + assets/quadrantDiagram-c759a472-ChaDXyQS.js | 7 + .../requirementDiagram-87253d64-Dkgz-B9P.js | 52 + assets/sankeyDiagram-707fac0f-BXumW6Uv.js | 8 + assets/sequenceDiagram-6894f283-8w1yt_A5.js | 122 +++ assets/stateDiagram-5dee940d-QdyITvZy.js | 1 + assets/stateDiagram-v2-1992cada-BYeiqQmL.js | 1 + assets/style-C0gC8zyW.css | 1 + assets/styles-0784dbeb-Bh_1Vl1K.js | 207 ++++ assets/styles-483fbfea-DnQzRaYr.js | 116 +++ assets/styles-b83b31c9-AroU2Phi.js | 160 +++ assets/svgDrawCommon-5e1cfd1d-B4aG73a4.js | 1 + .../timeline-definition-bf702344-BdCHgPg4.js | 61 ++ assets/xychartDiagram-f11f50a6-B4uqsceT.js | 7 + ...57\344\273\230\345\256\23510-BcbPWYKj.jpg" | Bin 0 -> 129027 bytes ...57\344\273\230\345\256\23520-ChMdJN1i.jpg" | Bin 0 -> 130293 bytes ...344\273\230\345\256\23588.88-cwJg-qu6.jpg" | Bin 0 -> 124963 bytes ...\253\346\227\213\350\275\254-xmR8DCB1.jpg" | Bin 0 -> 354705 bytes .../\350\265\236\345\212\251-Bu8SX5TV.jpg" | Bin 0 -> 171632 bytes ...\233\345\272\246\346\235\241-QdAFSPTh.png" | Bin 0 -> 22172 bytes homework/index.html | 40 + image/index.html | 40 + "image/\346\215\220\350\265\240/index.html" | 40 + ...3\350\231\253\346\227\213\350\275\254.jpg" | Bin 0 -> 354705 bytes ...6\347\250\213\346\225\231\347\250\213.png" | Bin 0 -> 42822 bytes "image/\350\265\236\345\212\251.jpg" | Bin 0 -> 171632 bytes .../\350\277\233\345\272\246\346\235\241.png" | Bin 0 -> 22172 bytes index.html | 40 + ...\346\234\254\346\246\202\345\277\265.html" | 40 + ...\347\224\250\347\272\277\347\250\213.html" | 420 ++++++++ ...\344\272\253\346\225\260\346\215\256.html" | 435 +++++++++ ...\346\255\245\346\223\215\344\275\234.html" | 907 ++++++++++++++++++ ...\345\255\220\346\223\215\344\275\234.html" | 299 ++++++ "md/06\345\215\217\347\250\213.html" | 40 + md/index.html | 55 ++ ...\347\240\201\350\247\243\346\236\220.html" | 93 ++ ...\347\240\201\350\247\243\346\236\220.html" | 116 +++ ...\347\240\201\350\247\243\346\236\220.html" | 378 ++++++++ ...\347\272\277\347\250\213\346\261\240.html" | 259 +++++ .../index.html" | 40 + search-pro.worker.js | 2 + 158 files changed, 8150 insertions(+) create mode 100644 404.html create mode 100644 SUMMARY.html create mode 100644 "assets/01thread\347\232\204\346\236\204\351\200\240\344\270\216\346\272\220\347\240\201\350\247\243\346\236\220.html-EFWzQ7p9.js" create mode 100644 "assets/01\345\237\272\346\234\254\346\246\202\345\277\265.html-BKokb-lV.js" create mode 100644 "assets/02scoped_lock\346\272\220\347\240\201\350\247\243\346\236\220.html-1I_9i2hp.js" create mode 100644 "assets/02\344\275\277\347\224\250\347\272\277\347\250\213.html-CuDgfpeg.js" create mode 100644 "assets/03async\344\270\216future\346\272\220\347\240\201\350\247\243\346\236\220.html-Cuvtvbpc.js" create mode 100644 "assets/03\345\205\261\344\272\253\346\225\260\346\215\256.html-BLgsE6xw.js" create mode 100644 "assets/04\345\220\214\346\255\245\346\223\215\344\275\234.html-rJexZaMy.js" create mode 100644 "assets/04\347\272\277\347\250\213\346\261\240.html-B8ceqDbR.js" create mode 100644 "assets/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.html-QZXnSagT.js" create mode 100644 "assets/06\345\215\217\347\250\213.html-f5RY1G-q.js" create mode 100644 assets/404.html-C-h4i3I8.js create mode 100644 assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 create mode 100644 assets/KaTeX_AMS-Regular-DMm9YOAa.woff create mode 100644 assets/KaTeX_AMS-Regular-DRggAlZN.ttf create mode 100644 assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf create mode 100644 assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff create mode 100644 assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 create mode 100644 assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff create mode 100644 assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 create mode 100644 assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf create mode 100644 assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf create mode 100644 assets/KaTeX_Fraktur-Bold-BsDP51OF.woff create mode 100644 assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 create mode 100644 assets/KaTeX_Fraktur-Regular-CB_wures.ttf create mode 100644 assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 create mode 100644 assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff create mode 100644 assets/KaTeX_Main-Bold-Cx986IdX.woff2 create mode 100644 assets/KaTeX_Main-Bold-Jm3AIy58.woff create mode 100644 assets/KaTeX_Main-Bold-waoOVXN0.ttf create mode 100644 assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 create mode 100644 assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf create mode 100644 assets/KaTeX_Main-BoldItalic-SpSLRI95.woff create mode 100644 assets/KaTeX_Main-Italic-3WenGoN9.ttf create mode 100644 assets/KaTeX_Main-Italic-BMLOBm91.woff create mode 100644 assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 create mode 100644 assets/KaTeX_Main-Regular-B22Nviop.woff2 create mode 100644 assets/KaTeX_Main-Regular-Dr94JaBh.woff create mode 100644 assets/KaTeX_Main-Regular-ypZvNtVU.ttf create mode 100644 assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf create mode 100644 assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 create mode 100644 assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff create mode 100644 assets/KaTeX_Math-Italic-DA0__PXp.woff create mode 100644 assets/KaTeX_Math-Italic-flOr_0UB.ttf create mode 100644 assets/KaTeX_Math-Italic-t53AETM-.woff2 create mode 100644 assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf create mode 100644 assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 create mode 100644 assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff create mode 100644 assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 create mode 100644 assets/KaTeX_SansSerif-Italic-DN2j7dab.woff create mode 100644 assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf create mode 100644 assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf create mode 100644 assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff create mode 100644 assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 create mode 100644 assets/KaTeX_Script-Regular-C5JkGWo-.ttf create mode 100644 assets/KaTeX_Script-Regular-D3wIWfF6.woff2 create mode 100644 assets/KaTeX_Script-Regular-D5yQViql.woff create mode 100644 assets/KaTeX_Size1-Regular-C195tn64.woff create mode 100644 assets/KaTeX_Size1-Regular-Dbsnue_I.ttf create mode 100644 assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 create mode 100644 assets/KaTeX_Size2-Regular-B7gKUWhC.ttf create mode 100644 assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 create mode 100644 assets/KaTeX_Size2-Regular-oD1tc_U0.woff create mode 100644 assets/KaTeX_Size3-Regular-CTq5MqoE.woff create mode 100644 assets/KaTeX_Size3-Regular-DgpXs0kz.ttf create mode 100644 assets/KaTeX_Size4-Regular-BF-4gkZK.woff create mode 100644 assets/KaTeX_Size4-Regular-DWFBv043.ttf create mode 100644 assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 create mode 100644 assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff create mode 100644 assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 create mode 100644 assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf create mode 100644 assets/SUMMARY.html-BnXBmIpJ.js create mode 100644 assets/SearchResult-jru87sjv.js create mode 100644 assets/Tableau10-B-NsZVaP.js create mode 100644 assets/app-Ca-A-iaw.js create mode 100644 assets/arc-BDtf7cdN.js create mode 100644 assets/array-BKyUJesY.js create mode 100644 assets/auto-Cl2ltNcc.js create mode 100644 assets/blockDiagram-9f4a6865-CCynznDb.js create mode 100644 assets/c4Diagram-ae766693-Cwq3C8UU.js create mode 100644 assets/channel-BQsRZjn0.js create mode 100644 assets/classDiagram-fb54d2a0-B6KDNP-K.js create mode 100644 assets/classDiagram-v2-a2b738ad-BzrN1-EV.js create mode 100644 assets/clone-CH2yQtrO.js create mode 100644 assets/createText-ca0c5216-bYdpW0Zp.js create mode 100644 assets/edges-066a5561-CYMTSPpJ.js create mode 100644 assets/erDiagram-09d1c15f-kySp2mgy.js create mode 100644 assets/flowDb-c1833063-Dab_m0tb.js create mode 100644 assets/flowDiagram-b222e15a-DarGeLKH.js create mode 100644 assets/flowDiagram-v2-13329dc7-B5Y-44JY.js create mode 100644 assets/flowchart-CTwbLKUk.js create mode 100644 assets/flowchart-elk-definition-ae0efee6-D2n0UGGT.js create mode 100644 assets/ganttDiagram-b62c793e-DZKZndxq.js create mode 100644 assets/gitGraphDiagram-942e62fe-B3EvEA8b.js create mode 100644 assets/graph-DkiqFSuU.js create mode 100644 assets/index-01f381cb-wllKWk9M.js create mode 100644 assets/index-AN989yVn.js create mode 100644 assets/index.html-BFILG7Me.js create mode 100644 assets/index.html-BHEgHCm0.js create mode 100644 assets/index.html-BNiJ94F3.js create mode 100644 assets/index.html-BV8xYf6R.js create mode 100644 assets/index.html-C6Z_qkjf.js create mode 100644 assets/index.html-CumviD6U.js create mode 100644 assets/infoDiagram-94cd232f-CmPJgRj5.js create mode 100644 assets/init-Gi6I4Gst.js create mode 100644 assets/journeyDiagram-6625b456-C95pQKsn.js create mode 100644 assets/katex-CvgdMzdh.js create mode 100644 assets/layout-BZLwH4ql.js create mode 100644 assets/line-DS2p6aKt.js create mode 100644 assets/linear-BnGacGIF.js create mode 100644 assets/mermaid.core-Bc1j4tiv.js create mode 100644 assets/mindmap-definition-307c710a-DdlwPr3Y.js create mode 100644 assets/ordinal-Cboi1Yqb.js create mode 100644 assets/path-CbwjOpE9.js create mode 100644 assets/photoswipe.esm-GXRgw7eJ.js create mode 100644 assets/pieDiagram-bb1d19e5-BQhQYyps.js create mode 100644 assets/plugin-vue_export-helper-DlAUqK2U.js create mode 100644 assets/quadrantDiagram-c759a472-ChaDXyQS.js create mode 100644 assets/requirementDiagram-87253d64-Dkgz-B9P.js create mode 100644 assets/sankeyDiagram-707fac0f-BXumW6Uv.js create mode 100644 assets/sequenceDiagram-6894f283-8w1yt_A5.js create mode 100644 assets/stateDiagram-5dee940d-QdyITvZy.js create mode 100644 assets/stateDiagram-v2-1992cada-BYeiqQmL.js create mode 100644 assets/style-C0gC8zyW.css create mode 100644 assets/styles-0784dbeb-Bh_1Vl1K.js create mode 100644 assets/styles-483fbfea-DnQzRaYr.js create mode 100644 assets/styles-b83b31c9-AroU2Phi.js create mode 100644 assets/svgDrawCommon-5e1cfd1d-B4aG73a4.js create mode 100644 assets/timeline-definition-bf702344-BdCHgPg4.js create mode 100644 assets/xychartDiagram-f11f50a6-B4uqsceT.js create mode 100644 "assets/\346\224\257\344\273\230\345\256\23510-BcbPWYKj.jpg" create mode 100644 "assets/\346\224\257\344\273\230\345\256\23520-ChMdJN1i.jpg" create mode 100644 "assets/\346\224\257\344\273\230\345\256\23588.88-cwJg-qu6.jpg" create mode 100644 "assets/\347\214\253\347\214\253\350\231\253\346\227\213\350\275\254-xmR8DCB1.jpg" create mode 100644 "assets/\350\265\236\345\212\251-Bu8SX5TV.jpg" create mode 100644 "assets/\350\277\233\345\272\246\346\235\241-QdAFSPTh.png" create mode 100644 homework/index.html create mode 100644 image/index.html create mode 100644 "image/\346\215\220\350\265\240/index.html" create mode 100644 "image/\347\214\253\347\214\253\350\231\253\346\227\213\350\275\254.jpg" create mode 100644 "image/\347\216\260\344\273\243C++\345\271\266\345\217\221\347\274\226\347\250\213\346\225\231\347\250\213.png" create mode 100644 "image/\350\265\236\345\212\251.jpg" create mode 100644 "image/\350\277\233\345\272\246\346\235\241.png" create mode 100644 index.html create mode 100644 "md/01\345\237\272\346\234\254\346\246\202\345\277\265.html" create mode 100644 "md/02\344\275\277\347\224\250\347\272\277\347\250\213.html" create mode 100644 "md/03\345\205\261\344\272\253\346\225\260\346\215\256.html" create mode 100644 "md/04\345\220\214\346\255\245\346\223\215\344\275\234.html" create mode 100644 "md/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.html" create mode 100644 "md/06\345\215\217\347\250\213.html" create mode 100644 md/index.html create mode 100644 "md/\350\257\246\347\273\206\345\210\206\346\236\220/01thread\347\232\204\346\236\204\351\200\240\344\270\216\346\272\220\347\240\201\350\247\243\346\236\220.html" create mode 100644 "md/\350\257\246\347\273\206\345\210\206\346\236\220/02scoped_lock\346\272\220\347\240\201\350\247\243\346\236\220.html" create mode 100644 "md/\350\257\246\347\273\206\345\210\206\346\236\220/03async\344\270\216future\346\272\220\347\240\201\350\247\243\346\236\220.html" create mode 100644 "md/\350\257\246\347\273\206\345\210\206\346\236\220/04\347\272\277\347\250\213\346\261\240.html" create mode 100644 "md/\350\257\246\347\273\206\345\210\206\346\236\220/index.html" create mode 100644 search-pro.worker.js diff --git a/404.html b/404.html new file mode 100644 index 00000000..ef423552 --- /dev/null +++ b/404.html @@ -0,0 +1,40 @@ + + +
+ + + + + + +std::thread
的构造-源码解析我们这单章是为了专门解释一下 C++11 引入的 std::thread
是如何构造的,是如何创建线程传递参数的,让你彻底了解这个类。
我们以 MSVC 实现的 std::thread
代码进行讲解,MSVC STL 很早之前就不支持 C++11 了,它的实现完全基于 C++14,出于某些原因 C++17 的一些库(如 invoke
, _v 变量模板)被向后移植到了 C++14 模式,所以即使是 C++11 标准库设施,实现中可能也是使用到了 C++14、17 的东西。
std::thread
的数据成员std::thread
只保有一个私有数据成员 _Thr
:
private:
+ _Thrd_t _Thr;
_Thrd_t
是一个结构体,它保有两个数据成员:
using _Thrd_id_t = unsigned int;
+struct _Thrd_t { // thread identifier for Win32
+ void* _Hnd; // Win32 HANDLE
+ _Thrd_id_t _Id;
+};
结构很明确,这个结构体的 _Hnd
成员是指向线程的句柄,_Id
成员就是保有线程的 ID。
在64 位操作系统,因为内存对齐,指针 8 ,无符号 int 4,这个结构体 _Thrd_t
就是占据 16 个字节。也就是说 sizeof(std::thread)
的结果应该为 16。
std::thread
的构造函数std::thread
有四个构造函数,分别是:
默认构造函数,构造不关联线程的新 std::thread 对象。
thread() noexcept : _Thr{} {}
移动构造函数,转移线程的所有权,构造 other 关联的执行线程的 std::thread
对象。此调用后 other 不再表示执行线程失去了线程的所有权。
thread(thread&& _Other) noexcept : _Thr(_STD exchange(_Other._Thr, {})) {}
_STD 是一个宏,展开就是 ::std::
,也就是 ::std::exchange
,将 _Other._Thr
赋为 {}
(也就是置空),返回 _Other._Thr
的旧值用以初始化当前对象的数据成员 _Thr
。
复制构造函数被定义为弃置的,std::thread 不可复制。两个 std::thread 不可表示一个线程,std::thread 对线程资源是独占所有权。
thread(const thread&) = delete;
构造新的 std::thread
对象并将它与执行线程关联。表示新的执行线程开始执行。
template <class _Fn, class... _Args, enable_if_t<!is_same_v<_Remove_cvref_t<_Fn>, thread>, int> = 0>
+ _NODISCARD_CTOR_THREAD explicit thread(_Fn&& _Fx, _Args&&... _Ax) {
+ _Start(_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...);
+ }
前三个构造函数都没啥要特别聊的,非常简单,只有第四个构造函数较为复杂,且是我们本章重点,需要详细讲解。(注意 MSVC 使用标准库的内容很多时候不加 std::,脑补一下就行)
如你所见,这个构造函数本身并没有做什么,它只是一个可变参数成员函数模板,增加了一些 SFINAE 进行约束我们传入的可调用对象的类型不能是 std::thread
。关于这个约束你可能有问题,因为 std::thread
他并没有 operator()
的重载,不是可调用类型,这个 enable_if_t
的意义是什么呢?其实很简单,如下:
struct X{
+ X(X&& x)noexcept{}
+ template <class Fn, class... Args>
+ X(Fn&& f,Args&&...args){}
+ X(const X&) = delete;
+};
+
+X x{ [] {} };
+X x2{ x }; // 选择到了有参构造函数,不导致编译错误
以上这段代码可以正常的通过编译。这是重载决议的事情,我们知道,std::thread
是不可复制的,这种代码自然不应该让它通过编译,选择到我们的有参构造,所以我们添加一个约束让其不能选择到我们的有参构造:
template <class Fn, class... Args, std::enable_if_t<!std::is_same_v<std::remove_cvref_t<Fn>, X>, int> = 0>
这样,这段代码就会正常的出现编译错误,信息如下:
error C2280: “X::X(const X &)”: 尝试引用已删除的函数
+note: 参见“X::X”的声明
+note: “X::X(const X &)”: 已隐式删除函数
也就满足了我们的要求,重载决议选择到了弃置复制构造函数产生编译错误,这也就是源码中添加约束的目的。
而构造函数体中调用了一个函数 _Start
,将我们构造函数的参数全部完美转发,去调用它,这个函数才是我们的重点,如下:
template <class _Fn, class... _Args>
+void _Start(_Fn&& _Fx, _Args&&... _Ax) {
+ using _Tuple = tuple<decay_t<_Fn>, decay_t<_Args>...>;
+ auto _Decay_copied = _STD make_unique<_Tuple>(_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...);
+ constexpr auto _Invoker_proc = _Get_invoke<_Tuple>(make_index_sequence<1 + sizeof...(_Args)>{});
+
+ _Thr._Hnd =
+ reinterpret_cast<void*>(_CSTD _beginthreadex(nullptr, 0, _Invoker_proc, _Decay_copied.get(), 0, &_Thr._Id));
+
+ if (_Thr._Hnd) { // ownership transferred to the thread
+ (void) _Decay_copied.release();
+ } else { // failed to start thread
+ _Thr._Id = 0;
+ _Throw_Cpp_error(_RESOURCE_UNAVAILABLE_TRY_AGAIN);
+ }
+}
它也是一个可变参数成员函数模板,接受一个可调用对象 _Fn
和一系列参数 _Args...
,这些东西用来创建一个线程。
using _Tuple = tuple<decay_t<_Fn>, decay_t<_Args>...>
auto _Decay_copied = _STD make_unique<_Tuple>(_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...)
make_unique
创建了一个独占指针,指向的是 _Tuple
类型的对象,存储了传入的函数对象和参数的副本。constexpr auto _Invoker_proc = _Get_invoke<_Tuple>(make_index_sequence<1 + sizeof...(_Args)>{})
_Get_invoke
函数,传入 _Tuple
类型和一个参数序列的索引序列(为了遍历形参包)。这个函数用于获取一个函数指针,指向了一个静态成员函数 _Invoke
,它是线程实际执行的函数。这两个函数都非常的简单,我们来看看: template <class _Tuple, size_t... _Indices>
+ _NODISCARD static constexpr auto _Get_invoke(index_sequence<_Indices...>) noexcept {
+ return &_Invoke<_Tuple, _Indices...>;
+ }
+
+ template <class _Tuple, size_t... _Indices>
+ static unsigned int __stdcall _Invoke(void* _RawVals) noexcept /* terminates */ {
+ // adapt invoke of user's callable object to _beginthreadex's thread procedure
+ const unique_ptr<_Tuple> _FnVals(static_cast<_Tuple*>(_RawVals));
+ _Tuple& _Tup = *_FnVals.get(); // avoid ADL, handle incomplete types
+ _STD invoke(_STD move(_STD get<_Indices>(_Tup))...);
+ _Cnd_do_broadcast_at_thread_exit(); // TRANSITION, ABI
+ return 0;
+ }
_Get_invoke 函数很简单,就是接受一个元组类型,和形参包的索引,传递给 _Invoke 静态成员函数模板,实例化,获取它的函数指针。
它的形参类型我们不再过多介绍,你只需要知道
index_sequence
这个东西可以用来接收一个由make_index_sequence
创建的索引形参包,帮助我们进行遍历元组即可。示例代码。
_Invoke 是重中之重,它是线程实际执行的函数,如你所见它的形参类型是 void*
,这是必须的,要符合 _beginthreadex
执行函数的类型要求。虽然是 void*
,但是我可以将它转换为 _Tuple*
类型,构造一个独占智能指针,然后调用 get() 成员函数获取底层指针,解引用指针,得到元组的引用初始化_Tup
。
此时,我们就可以进行调用了,使用 std::invoke
+ std::move
(默认移动) ,这里有一个形参包展开,_STD get<_Indices>(_Tup))...
,_Tup 就是 std::tuple 的引用,我们使用 std::get<>
获取元组存储的数据,需要传入一个索引,这里就用到了 _Indices
。展开之后,就等于 invoke 就接受了我们构造 std::thread 传入的可调用对象,调用可调用对象的参数,invoke 就可以执行了。
_Thr._Hnd = reinterpret_cast<void*>(_CSTD _beginthreadex(nullptr, 0, _Invoker_proc, _Decay_copied.get(), 0, &_Thr._Id))
_beginthreadex
函数来启动一个线程,并将线程句柄存储到 _Thr._Hnd
中。传递给线程的参数为 _Invoker_proc
(一个静态函数指针,就是我们前面讲的 _Invoke)和 _Decay_copied.get()
(存储了函数对象和参数的副本的指针)。if (_Thr._Hnd) {
_Thr._Hnd
不为空,则表示线程已成功启动,将独占指针的所有权转移给线程。(void) _Decay_copied.release()
} else { // failed to start thread
_Thr._Id = 0;
_Throw_Cpp_error(_RESOURCE_UNAVAILABLE_TRY_AGAIN);
需要注意,libstdc++ 和 libc++ 可能不同,就比如它们 64 位环境下 sizeof(std::thread)
的结果就可能是 8。libstdc++ 的实现只保有一个 std::thread::id
。参见。不过实测 gcc 不管是 win32
还是 POSIX
线程模型,线程对象的大小都是 8,宏 _GLIBCXX_HAS_GTHREADS
的值都为 1(GThread)。
class thread + { + public: +#ifdef _GLIBCXX_HAS_GTHREADS + using native_handle_type = __gthread_t; +#else + using native_handle_type = int; +#endif
__gthread_t
即void*
。
我们这里的源码解析涉及到的 C++ 技术很多,我们也没办法每一个都单独讲,那会显得文章很冗长,而且也不是重点。
相信你也感受到了,不会模板,你阅读标准库源码,是无稽之谈,市面上很多教程教学,教导一些实现容器,过度简化了,真要去出错了去看标准库的代码,那是不现实的。不需要模板的水平有多高,也不需要会什么元编程,但是基本的需求得能做到,得会,这里推荐一下:现代C++模板教程。
学习完了也不要忘记了回答最初的问题:
如何做到的默认按值复制?
_Start
的第一行代码展示了这一点。我们将传入的所有参数包装成一个元组类型,这些类型先经过 decay_t
处理,去除了引用与 cv 限定,自然就实现了默认复制。
using _Tuple = tuple<decay_t<_Fn>, decay_t<_Args>...>;
为什么需要 std::ref
?
实现中将类型先经过 decay
处理,如果要传递引用,则必须用类包装一下才行,使用 std::ref
函数就会返回一个包装对象。
如何支持只能移动的对象?
参数通过完美转发,最终调用时使用 std::move
,这在线程实际执行的函数 _Invoke
中体现出来:
_STD invoke(_STD move(_STD get<_Indices>(_Tup))...);
如何做到接受任意可调用对象?
源码的实现很简单,主要是通过两层包装,最终将 void*
指针转换到原类型,然后使用 std::invoke
进行调用。
如何创建的线程?
MSVC STL 调用 Win32 API _beginthreadex
创建线程;libstdc++ 调用 __gthread_create
函数创建线程,在 Windows 上实际上就是调用 CreateThread
。_beginthreadex
和 CreateThread
都是微软提供的用于创建线程的 C 风格接口,它们的主要区别在于前者依赖于 C 运行时库,而后者更适合纯 Windows API 的情况。使用 _beginthreadex
可以确保正确初始化和清理 C 运行时库资源,而 CreateThread
则适用于不依赖于 C 运行时库的环境。
传递参数一节中的:“std::thread
内部会将保有的参数副本转换为右值表达式进行传递”到底是如何做到的?
这就是第三个问题,差不多,无非是最后调用 std::invoke
函数之前,先 std::move
了。
我们这单章是为了专门解释一下 C++11 引入的 std::thread
是如何构造的,是如何创建线程传递参数的,让你彻底了解这个类。
我们以 MSVC 实现的 std::thread
代码进行讲解,MSVC STL 很早之前就不支持 C++11 了,它的实现完全基于 C++14,出于某些原因 C++17 的一些库(如 invoke
, _v 变量模板)被向后移植到了 C++14 模式,所以即使是 C++11 标准库设施,实现中可能也是使用到了 C++14、17 的东西。
在我们谈起“并发编程”,其实可以直接简单理解为“多线程编程”,我知道你或许有疑问:“那多进程呢?” C++ 语言层面没有进程的概念,并发支持库也不涉及多进程,所以在本教程中,不用在意。
我们主要使用标准 C++ 进行教学,也会稍微涉及一些其它库。
并发,指两个或两个以上的独立活动同时发生。
并发在生活中随处可见,我们可以一边走路一边说话,也可以两只手同时做不同的动作,又或者一边看电视一边吃零食。
计算机中的并发有两种方式:
多核机器的真正并行。
单核机器的任务切换。
在早期,一些单核机器,它要想并发,执行多个任务,那就只能是任务切换,任务切换会给你一种“好像这些任务都在同时执行”的假象。只有硬件上是多核的,才能进行真正的并行,也就是真正的”同时执行任务“。
在现在,我们日常使用的机器,基本上是二者都有。我们现在的 CPU 基本都是多核,而操作系统调度基本也一样有任务切换,因为要执行的任务非常之多,CPU 是很快的,但是核心却没有那么多,不可能每一个任务都单独给一个核心。大家可以打开自己电脑的任务管理器看一眼,进程至少上百个,线程更是上千。这基本不可能每一个任务分配一个核心,都并行,而且也没必要。正是任务切换使得这些后台任务可以运行,这样系统使用者就可以同时运行文字处理器、编译器、编辑器和 Web 浏览器。
',12)),t(n,{id:"mermaid-45",code:"eJxLL0osyFAIcbHmUgCC4tIkiIDS0yWzni3Y8WzOrqczVzyf1fJ8zvxnaxc/3bntxcIeJbBSEPAtzSnJdM4vSo1GVh4Ll3cOCDWMBhEoQkYgISMUIWOQkHEsF6bJCrq6dmBzcEsZ4ZYyRpgIMgMsHJJYnG0Y/WT37qddC9EcBpc3gsqjuRIubwyVBzoZJJeal8IFAPcdaKI="}),t(n,{id:"mermaid-46",code:"eJxLL0osyFAIcbHmUgCC4tIkiIDS096pzxbseDZn19OZK57Panmye/fTroVPO9qf9S5SAisFgeDMvPScVOf8otRoZPWxWBQo6OraKfgHRz+b3Ptk75znm3c/3z3/xYbmp7uWIVT7B4NVhSQWZxtGQ2w0fNa5/MXCnued7c/WbUWoBCuBGmlEwEwjuKFGUEONcBsKUewfbEzAUGO4ocZQQ40xDU3NS+HiAgCTxJpN"}),e[1]||(e[1]=a('事实上,对于这两个术语,并没有非常公认的说法。
有些人认为二者毫无关系,指代的东西完全不同。
有些人认为二者大多数时候是相同的,只是用于描述一些东西的时候关注点不同。
我喜欢第二种,那我们就讲第二种。
对多线程来说,这两个概念大部分是重叠的。对于很多人来说,它们没有什么区别。
这两个词是用来描述硬件同时执行多个任务的方式:
“并行”更加注重性能。使用硬件提高数据处理速度时,会讨论程序的并行性。
当关注重点在于任务分离或任务响应时,会讨论程序的并发性。
这两个术语存在的目的,就是为了区别多线程中不同的关注点。
概念从来不是我们的重点,尤其是某些说法准确性也一般,假设开发者对操作系统等知识有基本了解。
我们也不打算特别介绍什么 C++ 并发库的历史发展、什么时候你该使用多线程、什么时候不该使用多线程... 类似问题应该是看你自己的,而我们回到代码上即可。
',10))])}const h=l(r,[["render",o],["__file","01基本概念.html.vue"]]),g=JSON.parse('{"path":"/md/01%E5%9F%BA%E6%9C%AC%E6%A6%82%E5%BF%B5.html","title":"基本概念","lang":"zh-CN","frontmatter":{},"headers":[{"level":2,"title":"前言","slug":"前言","link":"#前言","children":[]},{"level":2,"title":"并发","slug":"并发","link":"#并发","children":[]},{"level":2,"title":"在计算机中的并发","slug":"在计算机中的并发","link":"#在计算机中的并发","children":[]},{"level":2,"title":"并发与并行","slug":"并发与并行","link":"#并发与并行","children":[]},{"level":2,"title":"总结","slug":"总结","link":"#总结","children":[]}],"git":{"createdTime":1709618654000,"updatedTime":1722709815000,"contributors":[{"name":"归故里","email":"3326284481@qq.com","commits":5},{"name":"mq白","email":"3326284481@qq.com","commits":3},{"name":"suzukaze","email":"1027743497@qq.com","commits":1}]},"readingTime":{"minutes":3.22,"words":965},"filePathRelative":"md/01基本概念.md","localizedDate":"2024年3月5日","excerpt":"\\n在我们谈起“并发编程”,其实可以直接简单理解为“多线程编程”,我知道你或许有疑问:“那多进程呢?” C++ 语言层面没有进程的概念,并发支持库也不涉及多进程,所以在本教程中,不用在意。
\\n我们主要使用标准 C++ 进行教学,也会稍微涉及一些其它库。
\\n并发,指两个或两个以上的独立活动同时发生。
\\n并发在生活中随处可见,我们可以一边走路一边说话,也可以两只手同时做不同的动作,又或者一边看电视一边吃零食。
\\n我们还是一样的,以 MSVC STL 实现的 std::scoped_lock
代码进行讲解,不用担心,我们也查看了 libstdc++
、libc++
的实现,并没有太多区别,更多的是一些风格上的。而且个人觉得 MSVC 的实现是最简单直观的。
std::scoped_lock
的数据成员std::scoped_lock
是一个类模板,它有两个特化,也就是有三个版本,其中的数据成员也是不同的。并且它们都不可移动不可复制,“管理类”应该如此。
主模板,是一个可变参数类模板,声明了一个类型形参包 _Mutexes
,存储了一个 std::tuple
,具体类型根据类型形参包决定。
_EXPORT_STD template <class... _Mutexes>
+class _NODISCARD_LOCK scoped_lock { // class with destructor that unlocks mutexes
+public:
+ explicit scoped_lock(_Mutexes&... _Mtxes) : _MyMutexes(_Mtxes...) { // construct and lock
+ _STD lock(_Mtxes...);
+ }
+
+ explicit scoped_lock(adopt_lock_t, _Mutexes&... _Mtxes) noexcept // strengthened
+ : _MyMutexes(_Mtxes...) {} // construct but don't lock
+
+ ~scoped_lock() noexcept {
+ _STD apply([](_Mutexes&... _Mtxes) { (..., (void) _Mtxes.unlock()); }, _MyMutexes);
+ }
+
+ scoped_lock(const scoped_lock&) = delete;
+ scoped_lock& operator=(const scoped_lock&) = delete;
+
+private:
+ tuple<_Mutexes&...> _MyMutexes;
+};
对模板类型形参包只有一个类型情况的偏特化,是不是很熟悉,和 lock_guard
几乎没有任何区别,保有一个互斥量的引用,构造上锁,析构解锁,提供一个额外的构造函数让构造的时候不上锁。所以用 scoped_lock
替代 lock_guard
不会造成任何额外开销。
template <class _Mutex>
+class _NODISCARD_LOCK scoped_lock<_Mutex> {
+public:
+ using mutex_type = _Mutex;
+
+ explicit scoped_lock(_Mutex& _Mtx) : _MyMutex(_Mtx) { // construct and lock
+ _MyMutex.lock();
+ }
+
+ explicit scoped_lock(adopt_lock_t, _Mutex& _Mtx) noexcept // strengthened
+ : _MyMutex(_Mtx) {} // construct but don't lock
+
+ ~scoped_lock() noexcept {
+ _MyMutex.unlock();
+ }
+
+ scoped_lock(const scoped_lock&) = delete;
+ scoped_lock& operator=(const scoped_lock&) = delete;
+
+private:
+ _Mutex& _MyMutex;
+};
对类型形参包为空的情况的全特化,没有数据成员。
template <>
+class scoped_lock<> {
+public:
+ explicit scoped_lock() = default;
+ explicit scoped_lock(adopt_lock_t) noexcept /* strengthened */ {}
+
+ scoped_lock(const scoped_lock&) = delete;
+ scoped_lock& operator=(const scoped_lock&) = delete;
+};
std::mutex m1,m2;
+
+std::scoped_lock<std::mutex> lc{ m1 }; // 匹配到偏特化版本 保有一个 std::mutex&
+std::scoped_lock<std::mutex, std::mutex> lc2{ m1,m2 }; // 匹配到主模板 保有一个 std::tuple<std::mutex&,std::mutex&>
+std::scoped_lock<> lc3; // 匹配到全特化版本 空
std::scoped_lock
的构造与析构在上一节讲 scoped_lock
的数据成员的时候已经把这个模板类的全部源码,三个版本的代码都展示了,就不再重复。
这三个版本中,只有两个版本需要介绍,也就是
那这两个的共同点是什么呢?构造上锁,析构解锁。这很明显,明确这一点我们就开始讲吧。
std::mutex m;
+void f(){
+ m.lock();
+ std::lock_guard<std::mutex> lc{ m, std::adopt_lock };
+}
+void f2(){
+ m.lock();
+ std::scoped_lock<std::mutex>sp{ std::adopt_lock,m };
+}
这段代码为你展示了 std::lock_guard
和 std::scoped_lock
形参包元素数量为一的偏特化的唯一区别:调用不会上锁的构造函数的参数顺序不同。那么到此也就够了。
接下来我们进入 std::scoped_lock
主模板的讲解:
explicit scoped_lock(_Mutexes&... _Mtxes) : _MyMutexes(_Mtxes...) { // construct and lock
+ _STD lock(_Mtxes...);
+ }
这个构造函数做了两件事情,初始化数据成员 _MyMutexes
让它保有这些互斥量的引用,以及给所有互斥量上锁,使用了 std::lock
帮助我们完成这件事情。
explicit scoped_lock(adopt_lock_t, _Mutexes&... _Mtxes) noexcept // strengthened
+ : _MyMutexes(_Mtxes...) {} // construct but don't lock
这个构造函数不上锁,只是初始化数据成员 _MyMutexes
让它保有这些互斥量的引用。
~scoped_lock() noexcept {
+ _STD apply([](_Mutexes&... _Mtxes) { (..., (void) _Mtxes.unlock()); }, _MyMutexes);
+}
析构函数就要稍微聊一下了,主要是用 std::apply
去遍历 std::tuple
,让元组保有的互斥量引用都进行解锁。简单来说是 std::apply
可以将元组存储的参数全部拿出,用于调用这个可变参数的可调用对象,我们就能利用折叠表达式展开形参包并对其调用 unlock()
。
不在乎其返回类型只用来实施它的副作用,显式转换为
(void)
也就是弃值表达式。在我们之前讲的std::thread
源码中也有这种用法。不过你可能有疑问:“我们的标准库的那些互斥量
unlock()
返回类型都是void
呀,为什么要这样?”的确,这是个好问题,libstdc++ 和 libc++ 都没这样做,或许 MSVC STL 想着会有人设计的互斥量让它的
unlock()
返回类型不为void
,毕竟 互斥体 (Mutex) 没有要求unlock()
的返回类型。
template< class F, class Tuple >
+constexpr decltype(auto) apply( F&& f, Tuple&& t );
这个函数模板接受两个参数,一个可调用 (Callable)对象 f,以及一个元组 t,用做调用 f 。我们可以自己简单实现一下它,其实不算难,这种遍历元组的方式在之前讲 std::thread
的源码的时候也提到过。
template<class Callable, class Tuple, std::size_t...index>
+constexpr decltype(auto) Apply_impl(Callable&& obj,Tuple&& tuple,std::index_sequence<index...>){
+ return std::invoke(std::forward<Callable>(obj), std::get<index>(std::forward<Tuple>(tuple))...);
+}
+
+template<class Callable, class Tuple>
+constexpr decltype(auto) apply(Callable&& obj, Tuple&& tuple){
+ return Apply_impl(std::forward<Callable>(obj), std::forward<Tuple>(tuple),
+ std::make_index_sequence<std::tuple_size_v<std::remove_reference_t<Tuple>>>{});
+}
其实就是把元组给解包了,利用了 std::index_sequence
+ std::make_index_sequence
然后就用 std::get
形参包展开用 std::invoke
调用可调用对象即可,非常经典的处理可变参数做法,这个非常重要,一定要会使用。
举一个简单的调用例子:
std::tuple<int, std::string, char> tuple{ 66,"😅",'c' };
+::apply([](const auto&... t) { ((std::cout << t << ' '), ...); }, tuple);
运行测试。
使用了折叠表达式展开形参包,打印了元组所有的元素。
如你所见,其实这很简单。至少使用与了解其设计原理是很简单的。唯一的难度或许只有那点源码,处理可变参数,这会涉及不少模板技术,既常见也通用。还是那句话:“不会模板,你阅读标准库源码,是无稽之谈”。
相对于 std::thread
的源码解析,std::scoped_lock
还是简单的多。
本单章专门介绍标准库在 C++17 引入的类模板 std::scoped_lock
的实现,让你对它再无疑问。
这会涉及到不少的模板技术,这没办法,就如同我们先前聊 std::thread
的构造与源码分析最后说的:“不会模板,你阅读标准库源码,是无稽之谈”。建议学习现代C++模板教程。
在标准 C++ 中,std::thread
可以指代线程,本章中,使用线程也就是代表使用 std::thread
(C++20 std::jthread
)。
本章围绕着它们来讲解。
在我们初学 C++ 的时候应该都写过这样一段代码:
#include <iostream>
+
+int main(){
+ std::cout << "Hello World!" << std::endl;
+}
这段代码将"Hello World!"写入到标准输出流,换行并刷新。
我们可以启动一个线程来做这件事情:
#include <iostream>
+#include <thread> // 引入线程支持头文件
+
+void hello(){ // 定义一个函数用作打印任务
+ std::cout << "Hello World" << std::endl;
+}
+
+int main(){
+ std::thread t{ hello };
+ t.join();
+}
std::thread t{ hello };
创建了一个线程对象 t
,将 hello
作为它的可调用(Callable)对象,在新线程中执行。线程对象关联了一个线程资源,我们无需手动控制,在线程对象构造成功,就自动在新线程开始执行函数 hello
。
t.join();
等待线程对象 t
关联的线程执行完毕,否则将一直阻塞。这里的调用是必须的,否则 std::thread
的析构函数将调用 std::terminate()
无法正确析构。
这是因为我们创建线程对象 t
的时候就关联了一个活跃的线程,调用 join()
就是确保线程对象关联的线程已经执行完毕,然后会修改对象的状态,让 std::thread::joinable()
返回 false
,表示线程对象目前没有关联活跃线程。std::thread
的析构函数,正是通过 joinable()
判断线程对象目前是否有关联活跃线程,如果为 true
,那么就当做有关联活跃线程,会调用 std::terminate()
。
如你所见,std::thread 高度封装,其成员函数也很少,我们可以轻易的创建线程执行任务,不过,它的用法也还远不止如此,我们慢慢介绍。
使用 hardware_concurrency
函数可以获得我们当前硬件支持的并发线程数量,它是 std::thread
的静态成员函数。
#include <iostream>
+#include <thread>
+
+int main(){
+ unsigned int n = std::thread::hardware_concurrency();
+ std::cout << "支持 " << n << " 个并发线程。\\n";
+}
本节其实是要普及一下计算机常识,一些古老的书籍比如 csapp 应该也会提到“超线程技术”。
英特尔® 超线程技术是一项硬件创新,允许在每个内核上运行多个线程。更多的线程意味着可以并行完成更多的工作。
AMD 超线程技术被称为 SMT(Simultaneous Multi-Threading),它与英特尔的技术实现有所不同,不过使用类似。
举个例子:一款 4 核心 8 线程的 CPU,这里的 8 线程其实是指所谓的逻辑处理器,也意味着这颗 CPU 最多可并行执行 8 个任务。
我们的 hardware_concurrency()
获取的值自然也会是 8。
当然了,都 2024 年了,我们还得考虑一个问题:“ 英特尔从 12 代酷睿开始,为其处理器引入了全新的“大小核”混合设计架构”。
比如我的 CPU i7 13700H
它是 14 核心,20 线程,有 8 个能效核,6 个性能核。不过我们说了,物理核心这个通常不看重,hardware_concurrency()
输出的值会为 20。
我们可以举个简单的例子运用这个值:
template<typename ForwardIt>
+auto sum(ForwardIt first, ForwardIt last){
+ using value_type = std::iter_value_t<ForwardIt>;
+ std::size_t num_threads = std::thread::hardware_concurrency();
+ std::ptrdiff_t distance = std::distance(first, last);
+
+ if(distance > 1024000){
+ // 计算每个线程处理的元素数量
+ std::size_t chunk_size = distance / num_threads;
+ std::size_t remainder = distance % num_threads;
+
+ // 存储每个线程的结果
+ std::vector<value_type> results { num_threads };
+
+ // 存储关联线程的线程对象
+ std::vector<std::thread> threads;
+
+ // 创建并启动线程
+ auto start = first;
+ for (std::size_t i = 0; i < num_threads; ++i) {
+ auto end = std::next(start, chunk_size + (i < remainder ? 1 : 0));
+ threads.emplace_back([start, end, &results, i] {
+ results[i] = std::accumulate(start, end, value_type{});
+ });
+ start = end; // 开始迭代器不断向前
+ }
+
+ // 等待所有线程执行完毕
+ for (auto& thread : threads)
+ thread.join();
+
+ // 汇总线程的计算结果
+ value_type total_sum = std::accumulate(results.begin(), results.end(), value_type{});
+ return total_sum;
+ }
+
+ value_type total_sum = std::accumulate(first, last, value_type{});
+ return total_sum;
+}
运行测试。
我们写了这样一个求和函数 sum
,接受两个迭代器计算它们范围中对象的和。
我们先获取了迭代器所指向的值的类型,定义了一个别名 value_type
,我们这里使用到的 std::iter_value_t
是 C++20 引入的,返回类型推导是 C++14 引入。如果希望代码可以在 C++11 的环境运行也可以自行修改为:
template<typename ForwardIt>
+typename std::iterator_traits<ForwardIt>::value_type sum(ForwardIt first, ForwardIt last);
运行测试。
num_threads
是当前硬件支持的并发线程的值。std::distance
用来计算 first 到 last 的距离,也就是我们要进行求和的元素个数了。
我们这里的设计比较简单,毕竟是初学,所以只对元素个数大于 1024000
的进行多线程求和,而小于这个值的则直接使用标准库函数 std::accumulate
求和即可。
多线程求和只需要介绍三个地方
chunk_size
是每个线程分配的任务,但是这是可能有余数的,比如 10 个任务分配三个线程,必然余 1。但是我们也需要执行这个任务,所以还定义了一个对象 remainder
,它存储的就是余数。
auto end = std::next(start, chunk_size + (i < remainder ? 1 : 0));
这行代码是获取当前线程的执行范围,其实也就是要 chunk_size
再加上我们的余数 remainder
。这里写了一个三目运算符是为了进行分配任务,比如:
假设有 3 个线程执行,并且余数是 2。那么,每个线程的处理情况如下:
i = 0
时,由于 0 < 2
,所以这个线程会多分配一个元素。i = 1
时,同样因为 1 < 2
,这个线程也会多分配一个元素。i = 2
时,由于 2 >= 2
,所以这个线程只处理平均数量的元素。这确保了剩余的 2 个元素被分配给了前两个线程,而第三个线程只处理了平均数量的元素。这样就确保了所有的元素都被正确地分配给了各个线程进行处理。
auto start = first;
在创建线程执行之前先定义了一个开始迭代器。在传递给线程执行的lambda表达式中,最后一行是:start = end;
这是为了让迭代器一直向前。
由于求和不涉及数据竞争之类的问题,所以我们甚至可以在刚讲完 Hello World
就手搓了一个“并行求和”的简单的模板函数。主要的难度其实在于对 C++ 的熟悉程度,而非对线程类 std::thread
的使用了,这里反而是最简单的,无非是用容器存储线程对象管理,最后进行 join()
罢了。
本节代码只是为了学习,而且只是百万数据通常没必要多线程,上亿的话差不多。如果你需要多线程求和,可以使用 C++17 引入的求和算法 std::reduce
并指明执行策略。它的效率接近我们实现的 sum
的两倍,当前环境核心越多数据越多,和单线程效率差距越明显。
在 C++ 标准库中,没有直接管理线程的机制,只能通过对象关联线程后,通过该对象来管理线程。类 std::thread
的对象就是指代线程的对象,而我们本节说的“线程管理”,其实也就是指管理 std::thread
对象。
使用 C++ 线程库启动线程,就是构造 std::thread 对象。
当然了,如果是默认构造之类的,那么
std::thread
线程对象没有关联线程的,自然也不会启动线程执行任务。std::thread t; // 构造不表示线程的新 std::thread 对象
我们上一节的示例是传递了一个函数给 std::thread
对象,函数会在新线程中执行。std::thread
支持的形式还有很多,只要是可调用(Callable)对象即可,比如重载了 operator()
的类对象(也可以直接叫函数对象)。
class Task{
+public:
+ void operator()()const {
+ std::cout << "operator()()const\\n";
+ }
+};
我们显然没办法直接像函数使用函数名一样,使用“类名”,函数名可以隐式转换到指向它的函数指针,而类名可不会直接变成对象,我们想使用 Task
自然就得构造对象了
std::thread t{ Task{} };
+t.join();
直接创建临时对象即可,可以简化代码并避免引入不必要的局部对象。
不过有件事情需要注意,当我们使用函数对象用于构造 std::thread
的时候,如果你传入的是一个临时对象,且使用的都是 “()
”小括号初始化,那么编译器会将此语法解析为函数声明。
std::thread t( Task() ); // 函数声明
这被编译器解析为函数声明,是一个返回类型为 std::thread
,函数名为 t
,接受一个返回 Task
的空参的函数指针类型,也就是 Task(*)()
。
之所以我们看着抽象是因为这里的形参是无名的,且写了个函数类型。
我们用一个简单的示例为你展示:
void h(int(int)); //#1 声明
+void h(int (*p)(int)){} //#2 定义
即使我还没有为你讲述概念,我相信你也发现了,#1 和 #2 的区别无非是,#1 省略了形参的名称,还有它的形参是函数类型而不是函数指针类型,没有 *
。
在确定每个形参的类型后,类型是 “T 的数组”或某个函数类型 T 的形参会调整为具有类型“指向 T 的指针”。文档。
显然,int(int)
是一个函数类型,它被调整为了一个指向这个函数类型的指针类型。
那么回到我们最初的:
std::thread t( Task() ); // #1 函数声明
+std::thread t( Task (*p)() ){ return {}; } // #2 函数定义
#2
我们写出了函数形参名称 p
,再将函数类型写成函数指针类型,事实上完全等价。我相信,这样,也就足够了。
所以总而言之,建议使用 {}
进行初始化,这是好习惯,大多数时候它是合适的。
C++11 引入的 Lambda 表达式,同样可以作为构造 std::thread
的参数,因为 Lambda 本身就是生成了一个函数对象,它自身就是类类型。
#include <iostream>
+#include <thread>
+
+int main(){
+ std::thread thread{ [] {std::cout << "Hello World!\\n"; } };
+ thread.join();
+}
启动线程后(也就是构造 std::thread
对象)我们必须在线程对象的生存期结束之前,即 std::thread::~thread
调用之前,决定它的执行策略,是 join()
(合并)还是 detach()
(分离)。
我们先前使用的就是 join(),我们聊一下 detach(),当 std::thread
线程对象调用了 detach(),那么就是线程对象放弃了对线程资源的所有权,不再管理此线程,允许此线程独立的运行,在线程退出时释放所有分配的资源。
放弃了对线程资源的所有权,也就是线程对象没有关联活跃线程了,此时 joinable 为 false
。
在单线程的代码中,对象销毁之后再去访问,会产生未定义行为,多线程增加了这个问题发生的几率。
比如函数结束,那么函数局部对象的生存期都已经结束了,都被销毁了,此时线程函数还持有函数局部对象的指针或引用。
#include <iostream>
+#include <thread>
+
+struct func {
+ int& m_i;
+ func(int& i) :m_i{ i } {}
+ void operator()(int n)const {
+ for (int i = 0; i <= n; ++i) {
+ m_i += i; // 可能悬空引用
+ }
+ }
+};
+
+int main(){
+ int n = 0;
+ std::thread my_thread{ func{n},100 };
+ my_thread.detach(); // 分离,不等待线程结束
+} // 分离的线程可能还在运行
主线程(main)创建局部对象 n、创建线程对象 my_thread 启动线程,执行任务 func{n}
,局部对象 n 的引用被子线程持有。传入 100 用于调用 func 的 operator(int)。
my_thread.detach();
,joinable() 为 false
。线程分离,线程对象不再持有线程资源,线程独立的运行。
主线程不等待,此时分离的子线程可能没有执行完毕,但是主线程(main)已经结束,局部对象 n
生存期结束,被销毁,而此时子线程还持有它的引用,访问悬空引用,造成未定义行为。my_thread
已经没有关联线程资源,正常析构,没有问题。
解决方法很简单,将 detach() 替换为 join()。
通常非常不推荐使用 detach(),因为程序员必须确保所有创建的线程正常退出,释放所有获取的资源并执行其它必要的清理操作。这意味着通过调用 detach() 放弃线程的所有权不是一种选择,因此 join 应该在所有场景中使用。 一些老式特殊情况不聊。
另外提示一下,也不要想着 detach() 之后,再次调用 join()
my_thread.detach();
+// todo..
+my_thread.join();
+// 函数结束
认为这样可以确保被分离的线程在这里阻塞执行完?
我们前面聊的很清楚了,detach() 是线程分离,线程对象放弃了线程资源的所有权,此时我们的 my_thread 它现在根本没有关联任何线程。调用 join() 是:“阻塞当前线程直至 *this 所标识的线程结束其执行”,我们的线程对象都没有线程,阻塞什么?执行什么呢?
简单点说,必须是 std::thread 的 joinable() 为 true 即线程对象有活跃线程,才能调用 join() 和 detach()。
顺带的,我们还得处理线程运行后的异常问题,举个例子:你在一个函数中构造了一个 std::thread 对象,线程开始执行,函数继续执行下面别的代码,但是如果抛出了异常呢?下面我的 join() 就会被跳过。
std::thread my_thread{func{n},10};
+//todo.. 抛出异常的代码
+my_thread.join();
避免程序被抛出的异常所终止,在异常处理过程中调用 join(),从而避免线程对象析构产生问题。
struct func; // 复用之前
+void f(){
+ int n = 0;
+ std::thread t{ func{n},10 };
+ try{
+ // todo.. 一些当前线程可能抛出异常的代码
+ f2();
+ }
+ catch (...){
+ t.join(); // 1
+ throw;
+ }
+ t.join(); // 2
+}
我知道你可能有很多疑问,我们既然 catch 接住了异常,为什么还要 throw?以及为什么我们要两个 join()?
这两个问题其实也算一个问题,如果代码里抛出了异常,就会跳转到 catch 的代码中,执行 join() 确保线程正常执行完成,线程对象可以正常析构。然而此时我们必须再次 throw 抛出异常,因为你要是不抛出,那么你不是还得执行一个 t.join()
?显然逻辑不对,自然抛出。
至于这个函数产生的异常,由调用方进行处理,我们只是确保函数 f 中创建的线程正常执行完成,其局部对象正常析构释放。测试代码。
我知道你可能会想到:“我在 try 块中最后一行写一个
t.join()
,这样如果前面的代码没有抛出异常,就能正常的调用 join(),如果抛出了异常,那就调用 catch 中的t.join()
根本不需要最外部 2 那里的 join(),也不需要再次throw
抛出异常”void f(){ + int n = 0; + std::thread t{ func{n},10 }; + try{ + // todo.. 一些当前线程可能抛出异常的代码 + f2(); + t.join(); // try 最后一行调用 join() + } + catch (...){ + t.join(); // 如果抛出异常,就在 这里调用 join() + } +}
你是否觉得这样也可以?也没问题?简单的测试运行的确没问题。
但是这是不对的,你要注意我们的注释:“一些当前线程可能抛出异常的代码”,而不是
f2()
,我们的try
catch
只是为了让线程对象关联的线程得以正确执行完毕,以及线程对象正确析构。并没有处理什么其他的东西,不掩盖错误,try
块中的代码抛出了异常,catch
接住了,我们理所应当再次抛出。
“资源获取即初始化”(RAII,Resource Acquisition Is Initialization)。
简单的说是:构造函数申请资源,析构函数释放资源,让对象的生命周期和资源绑定。当异常抛出时,C++ 会自动调用对象的析构函数。
我们可以提供一个类,在析构函数中使用 join() 确保线程执行完成,线程对象正常析构。
class thread_guard{
+ std::thread& m_t;
+public:
+ explicit thread_guard(std::thread& t) :m_t{ t } {}
+ ~thread_guard(){
+ std::puts("析构"); // 打印日志 不用在乎
+ if (m_t.joinable()) { // 线程对象当前关联了活跃线程
+ m_t.join();
+ }
+ }
+ thread_guard(const thread_guard&) = delete;
+ thread_guard& operator=(const thread_guard&) = delete;
+};
+void f(){
+ int n = 0;
+ std::thread t{ func{n},10 };
+ thread_guard g(t);
+ f2(); // 可能抛出异常
+}
函数 f 执行完毕,局部对象就要逆序销毁了。因此,thread_guard 对象 g 是第一个被销毁的,调用析构函数。即使函数 f2() 抛出了一个异常,这个销毁依然会发生(前提是你捕获了这个异常)。这确保了线程对象 t 所关联的线程正常的执行完毕以及线程对象的正常析构。测试代码。
如果异常被抛出但未被捕获那么就会调用 std::terminate。是否对未捕获的异常进行任何栈回溯由实现定义。(简单的说就是不一定会调用析构)
我们的测试代码是捕获了异常的,为了观测,看到它一定打印“析构”。
在 thread_guard 的析构函数中,我们要判断 std::thread
线程对象现在是否有关联的活跃线程,如果有,我们才会执行 join()
,阻塞当前线程直到线程对象关联的线程执行完毕。如果不想等待线程结束可以使用 detach()
,但是这让 std::thread
对象失去了线程资源的所有权,难以掌控,具体如何,看情况分析。
复制赋值和复制构造定义为 =delete
可以防止编译器隐式生成,同时会阻止移动构造函数和移动赋值运算符的隐式定义。这样的话,对 thread_guard 对象进行复制或赋值等操作会引发一个编译错误。
不允许这些操作主要在于:这是个管理类,而且顾名思义,它就应该只是单纯的管理线程对象仅此而已,只保有一个引用,单纯的做好 RAII 的事情就行,允许其他操作没有价值。
严格来说其实这里倒也不算 RAII,因为 thread_guard 的构造函数其实并没有申请资源,只是保有了线程对象的引用,在析构的时候进行了 join() 。
向可调用对象传递参数很简单,我们前面也都写了,只需要将这些参数作为 std::thread
的构造参数即可。需要注意的是,这些参数会复制到新线程的内存空间中,即使函数中的参数是引用,依然实际是复制。
void f(int, const int& a);
+
+int n = 1;
+std::thread t{ f, 3, n };
线程对象 t 的构造没有问题,可以通过编译,但是这个 n 实际上并没有按引用传递,而是按值复制的。我们可以打印地址来验证我们的猜想。
void f(int, const int& a) { // a 并非引用了局部对象 n
+ std::cout << &a << '\\n';
+}
+
+int main() {
+ int n = 1;
+ std::cout << &n << '\\n';
+ std::thread t{ f, 3, n };
+ t.join();
+}
运行代码,打印的地址截然不同。
可以通过编译,但通常这不符合我们的需求,因为我们的函数中的参数是引用,我们自然希望能引用调用方传递的参数,而不是复制。如果我们的 f 的形参类型不是 const 的引用,则会产生一个编译错误。
想要解决这个问题很简单,我们可以使用标准库的设施 std::ref
、 std::cref
函数模板。
void f(int, int& a) {
+ std::cout << &a << '\\n';
+}
+
+int main() {
+ int n = 1;
+ std::cout << &n << '\\n';
+ std::thread t { f, 3, std::ref(n) };
+ t.join();
+}
运行代码,打印地址完全相同。
我们来解释一下,“ref” 其实就是 “reference”(引用)的缩写,意思也很简单,返回“引用”,当然了,不是真的返回引用,它们返回一个包装类 std::reference_wrapper
,顾名思义,这个类就是包装引用对象类模板,将对象包装,可以隐式转换为被包装对象的引用。
“cref”呢?,这个“c”就是“const”,就是返回了 std::reference_wrapper<const T>
。我们不详细介绍他们的实现,你简单认为reference_wrapper
可以隐式转换为被包装对象的引用即可,
int n = 0;
+std::reference_wrapper<int> r = std::ref(n);
+int& p = r; // r 隐式转换为 n 的引用 此时 p 引用的就是 n
int n = 0;
+std::reference_wrapper<const int> r = std::cref(n);
+const int& p = r; // r 隐式转换为 n 的 const 的引用 此时 p 引用的就是 n
如果对他们的实现感兴趣,可以观看视频。
以上代码void f(int, int&)
如果不使用 std::ref
并不会和前面 void f(int, const int&)
一样只是多了复制,而是会产生编译错误,这是因为 std::thread
内部会将保有的参数副本转换为右值表达式进行传递,这是为了那些只支持移动的类型,左值引用没办法引用右值表达式,所以产生编译错误。
struct move_only {
+ move_only() { std::puts("默认构造"); }
+ move_only(const move_only&) = delete;
+ move_only(move_only&&)noexcept {
+ std::puts("移动构造");
+ }
+};
+
+void f(move_only){}
+
+int main(){
+ move_only obj;
+ std::thread t{ f,std::move(obj) };
+ t.join();
+}
运行测试。
没有 std::ref
自然是会保有一个副本,所以有两次移动构造,一次是被 std::thread
构造函数中初始化副本,一次是调用函数 f
。
如果还有不理解,不用担心,记住,这一切的问题都会在后面的 std::thread
的构造-源码解析 解释清楚。
成员函数指针也是可调用(Callable)的 ,可以传递给 std::thread
作为构造参数,让其关联的线程执行成员函数。
struct X{
+ void task_run(int)const;
+};
+
+ X x;
+ int n = 0;
+ std::thread t{ &X::task_run,&x,n };
+ t.join();
传入成员函数指针、与其配合使用的对象、调用成员函数的参数,构造线程对象 t
,启动线程。
如果你是第一次见到成员指针,那么我们稍微聊一下,&X::task_run
是一个整体,它们构成了成员指针,&类名::非静态成员。
成员指针必须和对象一起使用,这是唯一标准用法,成员指针不可以转换到函数指针单独使用,即使是非静态成员函数没有使用任何数据成员。
我们还可以使用模板函数 std::bind
与成员指针一起使用
std::thread t{ std::bind(&X::task_run, &x ,n) };
不过需要注意,std::bind
也是默认按值复制的,即使我们的成员函数形参类型为引用:
struct X {
+ void task_run(int& a)const{
+ std::cout << &a << '\\n';
+ }
+};
+
+X x;
+int n = 0;
+std::cout << &n << '\\n';
+std::thread t{ std::bind(&X::task_run,&x,n) };
+t.join();
除非给参数 n
加上 std::ref
,就是按引用传递了:
std::thread t{ std::bind(&X::task_run,&x,std::ref(n)) };
void f(const std::string&);
+std::thread t{ f,"hello" };
代码创建了一个调用 f("hello")
的线程。注意,函数 f
实际需要的是一个 std::string
类型的对象作为参数,但这里使用的是字符串字面量,我们要明白“A的引用只能引用A,或者以任何形式转换到A”,字符串字面量的类型是 const char[N]
,它会退化成指向它的const char*
指针,被线程对象保存。在调用 f
的时候,这个指针可以通过 std::string
的转换构造函数,构造出一个临时的 std::string
对象,就能成功调用。
字符串字面量具有静态存储期,指向它的指针这当然没问题了,不用担心生存期的问题,但是如果是指向“动态”对象的指针,就要特别注意了:
void f(const std::string&);
+void test(){
+ char buffer[1024]{};
+ //todo.. code
+ std::thread t{ f,buffer };
+ t.detach();
+}
以上代码可能导致一些问题,buffer 是一个数组对象,作为 std::thread
构造参数的传递的时候会decay-copy
(确保实参在按值传递时会退化) 隐式转换为了指向这个数组的指针。
我们要特别强调,std::thread
构造是代表“启动线程”,而不是调用我们传递的可调用对象。
std::thread
的构造函数中调用了创建线程的函数(windows 下可能为 _beginthreadex
),它将我们传入的参数,f、buffer ,传递给这个函数,在新线程中执行函数 f
。也就是说,调用和执行 f(buffer)
并不是说要在 std::thread
的构造函数中,而是在创建的新线程中,具体什么时候执行,取决于操作系统的调度,所以完全有可能函数 test
先执行完,而新线程此时还没有进行 f(buffer)
的调用,转换为std::string
,那么 buffer 指针就悬空了,会导致问题。解决方案:
将 detach()
替换为 join()
。
void f(const std::string&);
+void test(){
+ char buffer[1024]{};
+ //todo.. code
+ std::thread t{ f,buffer };
+ t.join();
+}
显式将 buffer
转换为 std::string
。
void f(const std::string&);
+void test(){
+ char buffer[1024]{};
+ //todo.. code
+ std::thread t{ f,std::string(buffer) };
+ t.detach();
+}
std::this_thread
这个命名空间包含了管理当前线程的函数。
yield
建议实现重新调度各执行线程。get_id
返回当前线程 id。sleep_for
使当前线程停止执行指定时间。sleep_until
使当前线程执行停止到指定的时间点。它们之中最常用的是 get_id
,其次是 sleep_for
,再然后 yield
,sleep_until
较少。
使用 get_id
打印主线程和子线程的 ID。
int main() {
+ std::cout << std::this_thread::get_id() << '\\n';
+
+ std::thread t{ [] {
+ std::cout << std::this_thread::get_id() << '\\n';
+ } };
+ t.join();
+}
使用 sleep_for
延时。当 Sleep
之类的就行,但是它需要接受的参数不同,是 std::chrono
命名空间中的时间对象。
int main() {
+ std::this_thread::sleep_for(std::chrono::seconds(3));
+}
主线程延时 3 秒,这个传入了一个临时对象 seconds
,它是模板 std::chrono::duration
的别名,以及还有很多其他的时间类型,都基于这个类。说实话挺麻烦的,如果您支持 C++14,建议使用时间字面量,在 std::chrono_literals
命名空间中。我们可以改成下面这样:
using namespace std::chrono_literals;
+
+int main() {
+ std::this_thread::sleep_for(3s);
+}
简单直观。
yield
减少 CPU 的占用。
while (!isDone()){
+ std::this_thread::yield();
+}
线程需要等待某个操作完成,如果你直接用一个循环不断判断这个操作是否完成就会使得这个线程占满 CPU 时间,这会造成资源浪费。此时可以判断操作是否完成,如果还没完成就调用 yield 交出 CPU 时间片让其他线程执行,过一会儿再来判断是否完成,这样这个线程占用 CPU 时间会大大减少。
使用 sleep_until
让当前线程延迟到具体的时间。我们延时 5 秒就是。
int main() {
+ // 获取当前时间点
+ auto now = std::chrono::system_clock::now();
+
+ // 设置要等待的时间点为当前时间点之后的5秒
+ auto wakeup_time = now + 5s;
+
+ // 输出当前时间
+ auto now_time = std::chrono::system_clock::to_time_t(now);
+ std::cout << "Current time:\\t\\t" << std::put_time(std::localtime(&now_time), "%H:%M:%S") << std::endl;
+
+ // 输出等待的时间点
+ auto wakeup_time_time = std::chrono::system_clock::to_time_t(wakeup_time);
+ std::cout << "Waiting until:\\t\\t" << std::put_time(std::localtime(&wakeup_time_time), "%H:%M:%S") << std::endl;
+
+ // 等待到指定的时间点
+ std::this_thread::sleep_until(wakeup_time);
+
+ // 输出等待结束后的时间
+ now = std::chrono::system_clock::now();
+ now_time = std::chrono::system_clock::to_time_t(now);
+ std::cout << "Time after waiting:\\t" << std::put_time(std::localtime(&now_time), "%H:%M:%S") << std::endl;
+}
sleep_until
本身设置使用很简单,是打印时间格式、设置时区麻烦。运行结果。
介绍了一下 std::this_thread
命名空间中的四个函数的基本用法,我们后续会经常看到这些函数的使用,不用着急。
std::thread
转移所有权传入可调用对象以及参数,构造 std::thread
对象,启动线程,而线程对象拥有了线程的所有权,线程是一种系统资源,所以可称作“线程资源”。
std::thread 不可复制。两个 std::thread 对象不可表示一个线程,std::thread 对线程资源是独占所有权。而移动操作可以将一个 std::thread
对象的线程资源所有权转移给另一个 std::thread
对象。
int main() {
+ std::thread t{ [] {
+ std::cout << std::this_thread::get_id() << '\\n';
+ } };
+ std::cout << t.joinable() << '\\n'; // 线程对象 t 当前关联了活跃线程 打印 1
+ std::thread t2{ std::move(t) }; // 将 t 的线程资源的所有权移交给 t2
+ std::cout << t.joinable() << '\\n'; // 线程对象 t 当前没有关联活跃线程 打印 0
+ //t.join(); // Error! t 没有线程资源
+ t2.join(); // t2 当前持有线程资源
+}
这段代码通过移动构造转移了线程对象 t
的线程资源所有权到 t2
,这里虽然有两个 std::thread
对象,但是从始至终只有一个线程资源,让持有线程资源的 t2
对象最后调用 join()
阻塞让其线程执行完毕。t
与 t2
都能正常析构。
我们还可以使用移动赋值来转移线程资源的所有权:
int main() {
+ std::thread t; // 默认构造,没有关联活跃线程
+ std::cout << t.joinable() << '\\n'; // 0
+ std::thread t2{ [] {} };
+ t = std::move(t2); // 转移线程资源的所有权到 t
+ std::cout << t.joinable() << '\\n'; // 1
+ t.join();
+
+ t2 = std::thread([] {});
+ t2.join();
+}
我们只需要介绍 t2 = std::thread([] {})
,临时对象是右值表达式,不用调用 std::move
,这里相当于是将临时的 std::thread
对象所持有的线程资源转移给 t2
,t2
再调用 join()
正常析构。
函数返回 std::thread
对象:
std::thread f(){
+ std::thread t{ [] {} };
+ return t;
+}
+
+int main(){
+ std::thread rt = f();
+ rt.join();
+}
这段代码可以通过编译,你是否感到奇怪?我们在函数 f 中创建了一个局部的 std::thread
对象,启动线程,然后返回它。
这里的 return t
重载决议[1]选择到了移动构造,将 t
线程资源的所有权转移给函数调用 f()
返回的临时 std::thread
对象中,然后这个临时对象再用来初始化 rt
,临时对象是右值表达式,这里一样选择到移动构造,将临时对象的线程资源所有权移交给 rt
。此时 rt
具有线程资源的所有权,由它调用 join()
正常析构。
如果标准达到 C++17,强制的复制消除(RVO)保证这里少一次移动构造的开销(临时对象初始化
rt
的这次)。
所有权也可以在函数内部传递:
void f(std::thread t){
+ t.join();
+}
+
+int main(){
+ std::thread t{ [] {} };
+ f(std::move(t));
+ f(std::thread{ [] {} });
+}
std::move
将 t 转换为了一个右值表达式,初始化函数f
形参 t
,选择到了移动构造转移线程资源的所有权,在函数中调用 t.join()
后正常析构。std::thread{ [] {} }
构造了一个临时对象,本身就是右值表达式,初始化函数f
形参 t
,移动构造转移线程资源的所有权到 t
,t.join()
后正常析构。
本节内容总体来说是很简单的,如果你有些地方无法理解,那只有一种可能,“对移动语义不了解”,不过这也不是问题,在后续我们详细介绍 std::thread
构造函数的源码即可,不用着急。
我们上一个大节讲解了线程管理,也就是 std::thread
的使用,其中的重中之重就是它的构造,传递参数。我们用源码实现为各位从头讲解。
了解其实现,才能更好的使用它,同时也能解释其使用与学习中的各种问题。如:
std::ref
?std::thread
内部会将保有的参数副本转换为右值表达式进行传递”到底是如何做到的?joining_thread
这个类和 std::thread
的区别就是析构函数会自动 join
。如果您好好的学习了上一节的内容,阅读了 std::thread
的源码,以下内容不会对您构成任何的难度。
我们存储一个 std::thread
作为底层数据成员,稍微注意一下构造函数和赋值运算符的实现即可。
class joining_thread {
+ std::thread t;
+public:
+ joining_thread()noexcept = default;
+ template<typename Callable, typename... Args>
+ explicit joining_thread(Callable&& func, Args&&...args) :
+ t{ std::forward<Callable>(func), std::forward<Args>(args)... } {}
+ explicit joining_thread(std::thread t_)noexcept : t{ std::move(t_) } {}
+ joining_thread(joining_thread&& other)noexcept : t{ std::move(other.t) } {}
+
+ joining_thread& operator=(std::thread&& other)noexcept {
+ if (joinable()) { // 如果当前有活跃线程,那就先执行完
+ join();
+ }
+ t = std::move(other);
+ return *this;
+ }
+ ~joining_thread() {
+ if (joinable()) {
+ join();
+ }
+ }
+ void swap(joining_thread& other)noexcept {
+ t.swap(other.t);
+ }
+ std::thread::id get_id()const noexcept {
+ return t.get_id();
+ }
+ bool joinable()const noexcept {
+ return t.joinable();
+ }
+ void join() {
+ t.join();
+ }
+ void detach() {
+ t.detach();
+ }
+ std::thread& data()noexcept {
+ return t;
+ }
+ const std::thread& data()const noexcept {
+ return t;
+ }
+};
简单使用一下:
int main(){
+ std::cout << std::this_thread::get_id() << '\\n';
+ joining_thread thread{[]{
+ std::cout << std::this_thread::get_id() << '\\n';
+ } };
+ joining_thread thread2{ std::move(thread) };
+}
使用容器管理线程对象,等待线程执行结束:
void do_work(std::size_t id){
+ std::cout << id << '\\n';
+}
+
+int main(){
+ std::vector<std::thread>threads;
+ for (std::size_t i = 0; i < 10; ++i){
+ threads.emplace_back(do_work, i); // 产生线程
+ }
+ for(auto& thread:threads){
+ thread.join(); // 对每个线程对象调用 join()
+ }
+}
运行测试。
线程对象代表了线程,管理线程对象也就是管理线程,这个 vector
对象管理 10 个线程,保证他们的执行、退出。
使用我们这节实现的 joining_thread
则不需要最后的循环 join()
:
int main(){
+ std::vector<joining_thread>threads;
+ for (std::size_t i = 0; i < 10; ++i){
+ threads.emplace_back(do_work, i);
+ }
+}
运行测试。
如果你自己编译了这些代码,相信你注意到了,打印的是乱序的,没什么规律,而且重复运行的结果还不一样,这是正常现象。多线程执行就是如此,无序且操作可能被打断。使用互斥量可以解决这些问题,这也就是下一章节的内容了。
std::jthread
std::jthread
相比于 C++11 引入的 std::thread
,只是多了两个功能:
RAII 管理:在析构时自动调用 join()
。
线程停止功能:线程的取消/停止。
我知道你肯定有疑问,为什么 C++20 不直接为 std::thread
增加这两个功能,而是创造一个新的线程类型呢?
这就是 C++ 的设计哲学,零开销原则:你不需要为你没有用到的(特性)付出额外的开销。
std::jthread
的通常实现就是单纯的保有 std::thread
+ std::stop_source
这两个数据成员:
thread _Impl;
+stop_source _Ssource;
MSVC STL、libstdc++、libc++ 均是如此。
stop_source
通常占 8 字节,先前 std::thread
源码解析详细聊过其不同标准库对其保有的成员不同,简单来说也就是 64 位环境,大小为 16 或者 8。也就是 sizeof(std::jthread)
的值相比 std::thread
会多 8 ,为 24
或 16
。
引入 std::jthread
符合零开销原则,它通过创建新的类型提供了更多的功能,而没有影响到原来 std::thread
的性能和内存占用。
第一个功能很简单,不用赘述,我们直接聊这个所谓的“线程停止”就好。
首先要明确,C++ 的 std::jthread
提供的线程停止功能并不同于常见的 POSIX 函数 pthread_cancel
。pthread_cancel
是一种发送取消请求的函数,但并不是强制性的线程终止方式。目标线程的可取消性状态和类型决定了取消何时生效。当取消被执行时,进行清理和终止线程[2]。
std::jthread
所谓的线程停止只是一种基于用户代码的控制机制,而不是一种与操作系统系统有关系的线程终止。使用 std::stop_source
和 std::stop_token
提供了一种优雅地请求线程停止的方式,但实际上停止的决定和实现都由用户代码来完成。
using namespace std::literals::chrono_literals;
+
+void f(std::stop_token stop_token, int value){
+ while (!stop_token.stop_requested()){ // 检查是否已经收到停止请求
+ std::cout << value++ << ' ' << std::flush;
+ std::this_thread::sleep_for(200ms);
+ }
+ std::cout << std::endl;
+}
+
+int main(){
+ std::jthread thread{ f, 1 }; // 打印 1..15 大约 3 秒
+ std::this_thread::sleep_for(3s);
+ // jthread 的析构函数调用 request_stop() 和 join()。
+}
运行测试。截止目前(
2024/5/29
clang19 未发布)libc++
不完全支持std::jthread
,建议使用clang
的开发者链接libstdc++
或MSVC STL
进行编译。如果非要使用libc++
,可以添加-fexperimental-library
编译选项,启用不稳定库功能和实验库功能。这样,我们的这段代码就可以通过编译。
std::jthread
提供了三个成员函数进行所谓的线程停止:
get_stop_source
:返回与 jthread
对象关联的 std::stop_source
,允许从外部请求线程停止。
get_stop_token
:返回与 jthread
对象停止状态[3]关联的 std::stop_token
,允许检查是否有停止请求。
request_stop
:请求线程停止。
上面这段代码并未出现这三个函数的任何一个调用,不过在 jthread
的析构函数中,会调用 request_stop
请求线程停止。
void _Try_cancel_and_join() noexcept {
+ if (_Impl.joinable()) {
+ _Ssource.request_stop();
+ _Impl.join();
+ }
+}
+~jthread() {
+ _Try_cancel_and_join();
+}
至于 std::jthread thread{ f, 1 };
函数 f 的 std::stop_token
的形参是谁传递的?其实就是线程对象自己调用 get_token()
传递的 ,源码一眼便可发现:
template <class _Fn, class... _Args, enable_if_t<!is_same_v<remove_cvref_t<_Fn>, jthread>, int> = 0>
+_NODISCARD_CTOR_JTHREAD explicit jthread(_Fn&& _Fx, _Args&&... _Ax) {
+ if constexpr (is_invocable_v<decay_t<_Fn>, stop_token, decay_t<_Args>...>) {
+ _Impl._Start(_STD forward<_Fn>(_Fx), _Ssource.get_token(), _STD forward<_Args>(_Ax)...);
+ } else {
+ _Impl._Start(_STD forward<_Fn>(_Fx), _STD forward<_Args>(_Ax)...);
+ }
+}
也就是说虽然最初的那段代码看似什么都没调用,但是实际什么都调用了。这所谓的线程停止,其实简单来说,有点像外部给线程传递信号一样。
std::stop_source
:
stop_source
的 request_stop()
方法时,它会设置内部的停止状态为“已请求停止”。stop_source
关联的 std::stop_token
对象都能检查到这个停止请求。std::stop_token
:
stop_token
是否收到了停止请求。stop_token.stop_requested()
,线程可以检测到停止状态是否已被设置为“已请求停止”。零开销原则应当很好理解。我们本节的难点只在于使用到了一些 MSVC STL 的源码实现来配合理解,其主要在于“线程停止”。线程停止设施你会感觉是一种类似于外部与线程进行某种信号通信的设施,std::stop_source
和 std::stop_token
都与线程对象关联,然后来管理函数到底如何执行。
我们并没有举很多的例子,我们觉得这一个小例子所牵扯到的内容也就足够了,关键在于理解其设计与概念。
本章节的内容围绕着:“使用线程”,也就是"使用 std::thread
"展开, std::thread
是我们学习 C++ 并发支持库的重中之重,在最后谈起了 C++20 引入的 std::jthread
,它的使用与概念也非常的简单。本章的内容在市面上并不算少见,但是却是少有的准确与完善。即使你早已学习乃至使用 C++ 标准库进行多线程编程,我相信本章也一定可以让你收获良多。
并且如果是第一次学习本章的内容,可能会有一些难以理解的地方。建议你多思考、多记忆,并在以后反复查看和实践。
我尽量以简单通俗的方式进行讲解。学完本章后,你可能还无法在实际环境利用多线程提升程序效率,至少还需要学习到使用互斥量来保护共享数据,才能实际应用多线程编程。
在标准 C++ 中,std::thread
可以指代线程,本章中,使用线程也就是代表使用 std::thread
(C++20 std::jthread
)。
本章围绕着它们来讲解。
\\n在我们初学 C++ 的时候应该都写过这样一段代码:
"}');export{A as comp,E as data}; diff --git "a/assets/03async\344\270\216future\346\272\220\347\240\201\350\247\243\346\236\220.html-Cuvtvbpc.js" "b/assets/03async\344\270\216future\346\272\220\347\240\201\350\247\243\346\236\220.html-Cuvtvbpc.js" new file mode 100644 index 00000000..cb288512 --- /dev/null +++ "b/assets/03async\344\270\216future\346\272\220\347\240\201\350\247\243\346\236\220.html-Cuvtvbpc.js" @@ -0,0 +1,339 @@ +import{_ as e}from"./plugin-vue_export-helper-DlAUqK2U.js";import{c as t,d as i,a as n,e as l,o as h,r as k}from"./app-Ca-A-iaw.js";const p={};function d(r,s){const a=k("Mermaid");return h(),t("div",null,[s[4]||(s[4]=i(`st::async
与 std::future
源码解析和之前一样的,我们以 MSVC STL 的实现进行讲解。
std::future
,即未来体,是用来管理一个共享状态的类模板,用于等待关联任务的完成并获取其返回值。它自身不包含状态,需要通过如 std::async
之类的函数进行初始化。std::async
函数模板返回一个已经初始化且具有共享状态的 std::future
对象。
因此,所有操作的开始应从 std::async
开始讲述。
需要注意的是,它们的实现彼此之间会共用不少设施,在讲述 std::async
源码的时候,对于 std::future
的内容同样重要。
MSVC STL 很早之前就不支持 C++11 了,它的实现完全基于 C++14,出于某些原因 C++17 的一些库(如
invoke
, _v 变量模板)被向后移植到了 C++14 模式,所以即使是 C++11 标准库设施,实现中可能也是使用到了 C++14、17 的东西。注意,不用感到奇怪。
std::async
_EXPORT_STD template <class _Fty, class... _ArgTypes>
+_NODISCARD_ASYNC future<_Invoke_result_t<decay_t<_Fty>, decay_t<_ArgTypes>...>> async(
+ launch _Policy, _Fty&& _Fnarg, _ArgTypes&&... _Args) {
+ // manages a callable object launched with supplied policy
+ using _Ret = _Invoke_result_t<decay_t<_Fty>, decay_t<_ArgTypes>...>;
+ using _Ptype = typename _P_arg_type<_Ret>::type;
+ _Promise<_Ptype> _Pr(
+ _Get_associated_state<_Ret>(_Policy, _Fake_no_copy_callable_adapter<_Fty, _ArgTypes...>(
+ _STD forward<_Fty>(_Fnarg), _STD forward<_ArgTypes>(_Args)...)));
+
+ return future<_Ret>(_From_raw_state_tag{}, _Pr._Get_state_for_future());
+}
+
+_EXPORT_STD template <class _Fty, class... _ArgTypes>
+_NODISCARD_ASYNC future<_Invoke_result_t<decay_t<_Fty>, decay_t<_ArgTypes>...>> async(
+ _Fty&& _Fnarg, _ArgTypes&&... _Args) {
+ // manages a callable object launched with default policy
+ return _STD async(launch::async | launch::deferred, _STD forward<_Fty>(_Fnarg), _STD forward<_ArgTypes>(_Args)...);
+}
这段代码最直观的信息是,函数模板 std::async
有两个重载,其中第二个重载只是给了一个执行策略并将参数全部转发,调用第一个重载。也就是不指明执行策略的时候就会匹配到第二个重载版本。因此我们也只需要关注第二个版本了。
模板参数和函数体外部信息:
_EXPOPT_STD
是一个宏,当 _BUILD_STD_MODULE
宏定义且启用了 C++20 时,会被定义为 export
,以便导出模块;否则它为空。
_Fty
表示可调用对象的类型。
_ArgTypes
是一个类型形参包,表示调用该可调用对象所需的参数类型。
_NODISCARD_ASYNC
是一个宏,表示属性 [[nodiscard]]
,用于标记此函数的返回值不应被忽略。
函数返回类型:
future<_Invoke_result_t<decay_t<_Fty>, decay_t<_ArgTypes>...>>
虽然看起来复杂,但实际上是通过 _Invoke_result_t
获取可调用对象的返回类型。与标准库中的 std::invoke_result_t
基本相同。
可以举一个使用 std::invoke_result_t
的例子:
template<class Fty, class... ArgTypes>
+std::future<std::invoke_result_t<std::decay_t<Fty>,std::decay_t<ArgTypes>...>>
+test_fun(Fty&& f,ArgTypes&&... args){
+ return std::async(std::launch::async, std::forward<Fty>(f), std::forward<ArgTypes>(args)...);
+}
+
+auto result = test_fun([](int) {return 1; }, 1); // std::future<int>
值得注意的是,所有类型在传递前都进行了 decay
处理,也就是说不存在引用类型,是默认按值传递与 std::thread
的行为一致。
函数形参:
async(launch _Policy, _Fty&& _Fnarg, _ArgTypes&&... _Args)
launch _Policy
: 表示任务的执行策略,可以是 launch::async
(表示异步执行)或 launch::deferred
(表示延迟执行),或者两者的组合。
_Fty&& _Fnarg
: 可调用对象,通过完美转发机制将其转发给实际的异步任务。
_ArgTypes&&... _Args
: 调用该可调用对象时所需的参数,同样通过完美转发机制进行转发。
using _Ret = _Invoke_result_t<decay_t<_Fty>, decay_t<_ArgTypes>...>;
using _Ptype = typename _P_arg_type<_Ret>::type;
定义 _Ret
类型别名,它是使用 _ArgTypes
类型参数调用 _Fty
类型的可调用对象后得到的结果类型。也就是我们传入的可调用对象的返回类型;同样使用了 _Invoke_result_t
(等价于 std::invoke_result_t
) 与 decay_t
。
其实 _Ptype
的定义确实在大多数情况下和 _Ret
是相同的,类模板 _P_arg_type 只是为了处理引用类型以及 void 的情况,参见 _P_arg_type
的实现:
template <class _Fret>
+struct _P_arg_type { // type for functions returning T
+ using type = _Fret;
+};
+
+template <class _Fret>
+struct _P_arg_type<_Fret&> { // type for functions returning reference to T
+ using type = _Fret*;
+};
+
+template <>
+struct _P_arg_type<void> { // type for functions returning void
+ using type = int;
+};
_Ptype
:处理异步任务返回值的方式类型,它在语义上强调了异步任务返回值的处理方式,具有不同的实现逻辑和使用场景。在当前我们难以直接展示它的作用,不过可以推测,这个“P
” 表示的是后文将使用的 _Promise
类模板。也就是说,定义 _Ptype
是为了配合 _Promise
的使用。我们将会在后文详细探讨 _Promise
类型的内部实现,并进一步解释 _Ptype
的具体作用。
_Promise<_Ptype> _Pr
_Promise
类型本身不重要,很简单,关键还在于其存储的数据成员。
template <class _Ty>
+class _Promise {
+public:
+ _Promise(_Associated_state<_Ty>* _State_ptr) noexcept : _State(_State_ptr, false), _Future_retrieved(false) {}
+
+ _Promise(_Promise&&) = default;
+
+ _Promise& operator=(_Promise&&) = default;
+
+ void _Swap(_Promise& _Other) noexcept {
+ _State._Swap(_Other._State);
+ _STD swap(_Future_retrieved, _Other._Future_retrieved);
+ }
+
+ const _State_manager<_Ty>& _Get_state() const noexcept {
+ return _State;
+ }
+ _State_manager<_Ty>& _Get_state() noexcept {
+ return _State;
+ }
+
+ _State_manager<_Ty>& _Get_state_for_set() {
+ if (!_State.valid()) {
+ _Throw_future_error2(future_errc::no_state);
+ }
+
+ return _State;
+ }
+
+ _State_manager<_Ty>& _Get_state_for_future() {
+ if (!_State.valid()) {
+ _Throw_future_error2(future_errc::no_state);
+ }
+
+ if (_Future_retrieved) {
+ _Throw_future_error2(future_errc::future_already_retrieved);
+ }
+
+ _Future_retrieved = true;
+ return _State;
+ }
+
+ bool _Is_valid() const noexcept {
+ return _State.valid();
+ }
+
+ bool _Is_ready() const noexcept {
+ return _State._Is_ready();
+ }
+
+ _Promise(const _Promise&) = delete;
+ _Promise& operator=(const _Promise&) = delete;
+
+private:
+ _State_manager<_Ty> _State;
+ bool _Future_retrieved;
+};
_Promise
类模板是对 _State_manager
类模板的包装,并增加了一个表示状态的成员 _Future_retrieved
。
状态成员用于跟踪 _Promise
是否已经调用过 _Get_state_for_future()
成员函数;它默认为 false
,在第一次调用 _Get_state_for_future()
成员函数时被置为 true
,如果二次调用,就会抛出 future_errc::future_already_retrieved
异常。
这类似于
std::promise
调用get_future()
成员函数。测试。
_Promise
的构造函数接受的却不是 _State_manager
类型的对象,而是 _Associated_state
类型的指针,用来初始化数据成员 _State
。
_State(_State_ptr, false)
这是因为实际上 _State_manager
类型的实现就是保有了 Associated_state
指针,以及一个状态成员:
private:
+ _Associated_state<_Ty>* _Assoc_state = nullptr;
+ bool _Get_only_once = false;
也可以简单理解 _State_manager
又是对 Associated_state
的包装,其中的大部分接口实际上是调用 _Assoc_state
的成员函数,如:
void wait() const { // wait for signal
+ if (!valid()) {
+ _Throw_future_error2(future_errc::no_state);
+ }
+
+ _Assoc_state->_Wait();
+}
Associated_state
上。然而它也是最为复杂的,我们在讲 std::thread
-构造源码解析 中提到过一句话:
- 了解一个庞大的类,最简单的方式就是先看它的数据成员有什么。
public:
+ conditional_t<is_default_constructible_v<_Ty>, _Ty, _Result_holder<_Ty>> _Result;
+ exception_ptr _Exception;
+ mutex _Mtx;
+ condition_variable _Cond;
+ bool _Retrieved;
+ int _Ready;
+ bool _Ready_at_thread_exit; // TRANSITION, ABI
+ bool _Has_stored_result;
+ bool _Running;
这是 Associated_state
的数据成员,其中有许多的 bool
类型的状态成员,同时最为明显重要的三个设施是:异常指针、互斥量、条件变量。
根据这些数据成员我们就能很轻松的猜测出 Associated_state
模板类的作用和工作方式。
future::get
时能够重新抛出异常。_Associated_state
模板类负责管理异步任务的状态,包括结果的存储、异常的处理以及任务完成的通知。它是实现 std::future
和 std::promise
的核心组件之一,通过 _State_manager
和 _Promise
类模板对其进行封装和管理,提供更高级别的接口和功能。
+---------------------+
+| _Promise<_Ty> |
+|---------------------|
+| - _State | -----> +---------------------+
+| - _Future_retrieved | | _State_manager<_Ty> |
++---------------------+ |----------------------|
+ | - _Assoc_state | -----> +-------------------------+
+ | - _Get_only_once | | _Associated_state<_Ty>* |
+ +----------------------+ +-------------------------+
上图是 _Promise
、_State_manager
、_Associated_state
之间的包含关系示意图,理解这个关系对我们后面非常重要。
到此就可以了,我们不需要在此处就详细介绍这三个类,但是你需要大概的看一下,这非常重要。
初始化 _Promie
对象:
_Get_associated_state<_Ret>(_Policy, _Fake_no_copy_callable_adapter<_Fty, _ArgTypes...>(_STD forward<_Fty>(_Fnarg), _STD forward<_ArgTypes>(_Args)...))
很明显,这是一个函数调用,将我们 std::async
的参数全部转发给它,它是重要而直观的。
_Get_associated_state
函数根据启动模式(launch
)来决定创建的异步任务状态对象类型:
template <class _Ret, class _Fty>
+_Associated_state<typename _P_arg_type<_Ret>::type>* _Get_associated_state(launch _Psync, _Fty&& _Fnarg) {
+ // construct associated asynchronous state object for the launch type
+ switch (_Psync) { // select launch type
+ case launch::deferred:
+ return new _Deferred_async_state<_Ret>(_STD forward<_Fty>(_Fnarg));
+ case launch::async: // TRANSITION, fixed in vMajorNext, should create a new thread here
+ default:
+ return new _Task_async_state<_Ret>(_STD forward<_Fty>(_Fnarg));
+ }
+}
_Get_associated_state
函数返回一个 _Associated_state
指针,该指针指向一个新的 _Deferred_async_state
或 _Task_async_state
对象。这两个类分别对应于异步任务的两种不同执行策略:延迟执行和异步执行。
其实就是父类指针指向了子类对象,注意
_Associated_state
是有虚函数的,子类进行覆盖,这很重要。比如在后续聊std::future
的get()
成员函数的时候就会讲到
这段代码也很好的说明在 MSVC STL 中,
launch::async | launch::deferred
和launch::async
的行为是相同的,即都是异步执行。
_Task_async_state
与 _Deferred_async_state
类型
template <class _Rx>
+class _Task_async_state : public _Packaged_state<_Rx()>
+template <class _Rx>
+class _Deferred_async_state : public _Packaged_state<_Rx()>
_Task_async_state
与 _Deferred_async_state
都继承自 _Packaged_state
,用于异步执行任务。它们的构造函数都接受一个函数对象,并将其转发给基类 _Packaged_state
的构造函数。
_Packaged_state
类型只有一个数据成员 std::function
类型的对象 _Fn
,它用来存储需要执行的异步任务,而它又继承自 _Associated_state
。
template <class _Ret, class... _ArgTypes>
+class _Packaged_state<_Ret(_ArgTypes...)>
+ : public _Associated_state<_Ret>
我们直接先看 _Task_async_state
与 _Deferred_async_state
类型的构造函数实现即可:
template <class _Fty2>
+_Task_async_state(_Fty2&& _Fnarg) : _Mybase(_STD forward<_Fty2>(_Fnarg)) {
+ _Task = ::Concurrency::create_task([this]() { // do it now
+ this->_Call_immediate();
+ });
+
+ this->_Running = true;
+}
+template <class _Fty2>
+_Deferred_async_state(const _Fty2& _Fnarg) : _Packaged_state<_Rx()>(_Fnarg) {}
+
+template <class _Fty2>
+_Deferred_async_state(_Fty2&& _Fnarg) : _Packaged_state<_Rx()>(_STD forward<_Fty2>(_Fnarg)) {}
_Task_async_state
它的数据成员:
private:
+ ::Concurrency::task<void> _Task;
_Task_async_state
的实现使用到了微软自己实现的 并行模式库(PPL),简而言之 launch::async
策略并不是单纯的创建线程让任务执行,而是使用了微软的 ::Concurrency::create_task
,它从线程池中获取线程并执行任务返回包装对象。
this->_Call_immediate();
是调用 _Task_async_state
的父类 _Packaged_state
的成员函数 _Call_immediate
。
_Packaged_state
有三个偏特化,_Call_immediate
自然也拥有三个不同版本,用来应对我们传入的函数对象返回类型的三种情况:
void _Call_immediate(_ArgTypes... _Args) {
+ _TRY_BEGIN
+ // 调用函数对象并捕获异常 传递返回值
+ this->_Set_value(_Fn(_STD forward<_ArgTypes>(_Args)...), false);
+ _CATCH_ALL
+ // 函数对象抛出异常就记录
+ this->_Set_exception(_STD current_exception(), false);
+ _CATCH_END
+}
void _Call_immediate(_ArgTypes... _Args) {
+ _TRY_BEGIN
+ // 调用函数对象并捕获异常 传递返回值的地址
+ this->_Set_value(_STD addressof(_Fn(_STD forward<_ArgTypes>(_Args)...)), false);
+ _CATCH_ALL
+ // 函数对象抛出异常就记录
+ this->_Set_exception(_STD current_exception(), false);
+ _CATCH_END
+}
_Packaged_state<void(_ArgTypes...)>
void _Call_immediate(_ArgTypes... _Args) { // call function object
+ _TRY_BEGIN
+ // 调用函数对象并捕获异常 因为返回 void 不获取返回值 而是直接 _Set_value 传递一个 1
+ _Fn(_STD forward<_ArgTypes>(_Args)...);
+ this->_Set_value(1, false);
+ _CATCH_ALL
+ // 函数对象抛出异常就记录
+ this->_Set_exception(_STD current_exception(), false);
+ _CATCH_END
+}
说白了,无非是把返回引用类型的可调用对象返回的引用获取地址传递给 _Set_value
,把返回 void 类型的可调用对象传递一个 1 表示正确执行的状态给 _Set_value
。
_Call_immediate
则又调用了父类 _Associated_state
的成员函数(_Set_value
、_set_exception
),传递的可调用对象执行结果,以及可能的异常,将结果或异常存储在 _Associated_state
中。
_Deferred_async_state
并不会在线程中执行任务,但它同样调用 _Call_immediate
函数执行保有的函数对象,它有一个 _Run_deferred_function
函数:
void _Run_deferred_function(unique_lock<mutex>& _Lock) override { // run the deferred function
+ _Lock.unlock();
+ _Packaged_state<_Rx()>::_Call_immediate();
+ _Lock.lock();
+}
然后也就和上面说的没什么区别了 。
`,18))]),s[3]||(s[3]=i(`返回 std::future
return future<_Ret>(_From_raw_state_tag{}, _Pr._Get_state_for_future());
它选择到了 std::future
的构造函数是:
future(_From_raw_state_tag, const _Mybase& _State) noexcept : _Mybase(_State, true) {}
using _Mybase = _State_manager<_Ty*>;
_From_raw_state_tag
是一个空类,并没有什么特殊作用,只是为了区分重载。
_Get_state_for_future
代码如下:
_State_manager<_Ty>& _Get_state_for_future() {
+ if (!_State.valid()) {
+ _Throw_future_error2(future_errc::no_state);
+ }
+
+ if (_Future_retrieved) {
+ _Throw_future_error2(future_errc::future_already_retrieved);
+ }
+
+ _Future_retrieved = true;
+ return _State;
+}
检查状态,修改状态,返回底层 _State
,完成转移状态。
总而言之这行代码通过调用 std::future
的特定构造函数,将 _Promise
对象中的 _State_manager
状态转移到 std::future
对象中,从而创建并返回一个 std::future
对象。这使得 std::future
可以访问并管理异步任务的状态,包括获取任务的结果或异常,并等待任务的完成。
std::future
先前的 std::async
的内容非常之多,希望各位开发者不要搞晕了,其实重中之重主要是那几个类,关系图如下:
+---------------------+
+| _Promise<_Ty> |
+|---------------------|
+| - _State | -----> +---------------------+
+| - _Future_retrieved | | _State_manager<_Ty> |
++---------------------+ |----------------------|
+ | - _Assoc_state | -----> +-------------------------+
+ | - _Get_only_once | | _Associated_state<_Ty>* |
+ +----------------------+ +-------------------------+
`,4)),l(a,{id:"mermaid-332",code:"eJxLzkksLnbJTEwvSszlUlBQAPMV4h2Li/OTMxNLUlPii0uAlEI1SBIE9PT0QMxaLiTVAYnJ2YnpmGp1i0tSrKzSSvOSSzLz8xTi3fIwtIYkFmfHJxZX5iVjaLaycs7PSy4tKkrNS660sioBqrQpy89MsYPowjDKJTUtFag4BdM4iDJMT9nU6OpiuN5K4fnu5c8694O1oMlBNGC4mbAWrG6Da+MCAFPNg0c="}),s[6]||(s[6]=i(`
_Promise
、_State_manager
、_Associated_state
之间的包含关系示意图。
_Asscociated_state
、_Packaged_state
、_Task_async_state
、_Deferred_async_state
继承关系示意图。
这其中的 _Associated_state
、_State_manager
类型是我们的核心,它在后续 std::future
乃至其它并发设施都有众多使用。
介绍 std::future
的源码我认为无需过多篇幅或者示例,引入过多的源码实现等等从头讲解,只会让各位开发者感觉复杂难。
我们直接从它的最重要、常见的 get()
、wait()
成员函数开始即可。
std::future<int> future = std::async([] { return 0; });
+future.get();
我们先前已经详细介绍过了 std::async
返回 std::future
的步骤。以上这段代码,唯一的问题是:future.get()
做了什么?
_EXPORT_STD template <class _Ty>
+class future : public _State_manager<_Ty> {
+ // class that defines a non-copyable asynchronous return object that holds a value
+private:
+ using _Mybase = _State_manager<_Ty>;
+
+public:
+ static_assert(!is_array_v<_Ty> && is_object_v<_Ty> && is_destructible_v<_Ty>,
+ "T in future<T> must meet the Cpp17Destructible requirements (N4950 [futures.unique.future]/4).");
+
+ future() = default;
+
+ future(future&& _Other) noexcept : _Mybase(_STD move(_Other), true) {}
+
+ future& operator=(future&&) = default;
+
+ future(_From_raw_state_tag, const _Mybase& _State) noexcept : _Mybase(_State, true) {}
+
+ _Ty get() {
+ // block until ready then return the stored result or throw the stored exception
+ future _Local{_STD move(*this)};
+ return _STD move(_Local._Get_value());
+ }
+
+ _NODISCARD shared_future<_Ty> share() noexcept {
+ return shared_future<_Ty>(_STD move(*this));
+ }
+
+ future(const future&) = delete;
+ future& operator=(const future&) = delete;
+};
std::future
其实还有两种特化,不过整体大差不差。template <class _Ty> +class future<_Ty&> : public _State_manager<_Ty*>
template <> +class future<void> : public _State_manager<int>
也就是对返回类型为引用和 void 的情况了。其实先前已经聊过很多次了,无非就是内部的返回引用实际按指针操作,返回 void,那么也得给个 1。参见前面的
_Call_immediate
实现。
可以看到 std::future
整体代码实现很少,很简单,那是因为其实现细节都在其父类 _State_manager
。然而 _State_manager
又保有一个 _Associated_state<_Ty>*
类型的成员。而 _Associated_state
又是一切的核心,之前已经详细描述过了。
阅读 std::future
的源码你可能注意到了一个问题:*没有 wait()
成员函数?
它的定义来自于父类 _State_manager
:
void wait() const { // wait for signal
+ if (!valid()) {
+ _Throw_future_error2(future_errc::no_state);
+ }
+
+ _Assoc_state->_Wait();
+}
然而这还不够,实际上还需要调用了 _Associated_state
的 wait()
成员函数:
virtual void _Wait() { // wait for signal
+ unique_lock<mutex> _Lock(_Mtx);
+ _Maybe_run_deferred_function(_Lock);
+ while (!_Ready) {
+ _Cond.wait(_Lock);
+ }
+}
先使用锁进行保护,然后调用函数,再循环等待任务执行完毕。_Maybe_run_deferred_function
:
void _Maybe_run_deferred_function(unique_lock<mutex>& _Lock) { // run a deferred function if not already done
+ if (!_Running) { // run the function
+ _Running = true;
+ _Run_deferred_function(_Lock);
+ }
+}
_Run_deferred_function
相信你不会陌生,在讲述 std::async
源码中其实已经提到了,就是解锁然后调用 _Call_immediate
罢了。
void _Run_deferred_function(unique_lock<mutex>& _Lock) override { // run the deferred function
+ _Lock.unlock();
+ _Packaged_state<_Rx()>::_Call_immediate();
+ _Lock.lock();
+}
_Call_immediate
就是执行我们实际传入的函数对象,先前已经提过。
在 _Wait
函数中调用 _Maybe_run_deferred_function
是为了确保延迟执行(launch::deferred
)的任务能够在等待前被启动并执行完毕。这样,在调用 wait
时可以正确地等待任务完成。
至于下面的循环等待部分:
while (!_Ready) {
+ _Cond.wait(_Lock);
+}
这段代码使用了条件变量、互斥量、以及一个状态对象,主要目的有两个:
wait
函数在被唤醒后,会重新检查条件(即 _Ready
是否为 true
),确保只有在条件满足时才会继续执行。这防止了由于虚假唤醒导致的错误行为。launch::async
的任务在其它线程执行完毕: launch::async
模式的任务,这段代码确保当前线程会等待任务在另一个线程中执行完毕,并接收到任务完成的信号。只有当任务完成并设置 _Ready
为 true
后,条件变量才会被通知,从而结束等待。这样,当调用 wait
函数时,可以保证无论任务是 launch::deferred
还是 launch::async
模式,当前线程都会正确地等待任务的完成信号,然后继续执行。
wait()
介绍完了,那么接下来就是 get()
:
// std::future<void>
+void get() {
+ // block until ready then return or throw the stored exception
+ future _Local{_STD move(*this)};
+ _Local._Get_value();
+}
+// std::future<T>
+_Ty get() {
+ // block until ready then return the stored result or throw the stored exception
+ future _Local{_STD move(*this)};
+ return _STD move(_Local._Get_value());
+}
+// std::future<T&>
+_Ty& get() {
+ // block until ready then return the stored result or throw the stored exception
+ future _Local{_STD move(*this)};
+ return *_Local._Get_value();
+}
在第四章的 “future 的状态变化”一节中我们也详细聊过 get()
成员函数。由于 future 本身有三个特化,get()
成员函数自然那也有三个版本,不过总体并无多大区别。
它们都是将当前对象(*this
)的共享状态转移给了这个局部对象 _Local
,然后再去调用父类_State_manager
的成员函数 _Get_value()
获取值并返回。而局部对象 _Local
在函数结束时析构。这意味着当前对象(*this
)失去共享状态,并且状态被完全销毁。
_Get_value()
:
_Ty& _Get_value() const {
+ if (!valid()) {
+ _Throw_future_error2(future_errc::no_state);
+ }
+
+ return _Assoc_state->_Get_value(_Get_only_once);
+}
先进行一下状态判断,如果拥有共享状态则继续,调用 _Assoc_state
的成员函数 _Get_value
,传递 _Get_only_once
参数,其实就是代表这个成员函数只能调用一次,次参数是里面进行状态判断的而已。
_Assoc_state
的类型是 _Associated_state<_Ty>*
,是一个指针类型,它实际会指向自己的子类对象,我们在讲 std::async
源码的时候提到了,它必然指向 _Deferred_async_state
或者 _Task_async_state
。
_Assoc_state->_Get_value
这其实是个多态调用,父类有这个虚函数:
virtual _Ty& _Get_value(bool _Get_only_once) {
+ unique_lock<mutex> _Lock(_Mtx);
+ if (_Get_only_once && _Retrieved) {
+ _Throw_future_error2(future_errc::future_already_retrieved);
+ }
+
+ if (_Exception) {
+ _STD rethrow_exception(_Exception);
+ }
+
+ // TRANSITION: \`_Retrieved\` should be assigned before \`_Exception\` is thrown so that a \`future::get\`
+ // that throws a stored exception invalidates the future (N4950 [futures.unique.future]/17)
+ _Retrieved = true;
+ _Maybe_run_deferred_function(_Lock);
+ while (!_Ready) {
+ _Cond.wait(_Lock);
+ }
+
+ if (_Exception) {
+ _STD rethrow_exception(_Exception);
+ }
+
+ if constexpr (is_default_constructible_v<_Ty>) {
+ return _Result;
+ } else {
+ return _Result._Held_value;
+ }
+}
但是子类 _Task_async_state
进行了重写,以 launch::async
策略创建的 future,那么实际会调用 _Task_async_state::_Get_value
:
_State_type& _Get_value(bool _Get_only_once) override {
+ // return the stored result or throw stored exception
+ _Task.wait();
+ return _Mybase::_Get_value(_Get_only_once);
+}
_Deferred_async_state
则没有进行重写,就是直接调用父类虚函数。
_Task
就是 ::Concurrency::task<void> _Task;
,调用 wait()
成员函数确保任务执行完毕。
_Mybase::_Get_value(_Get_only_once)
其实又是回去调用父类的虚函数了。
_Get_value
方法详细解释
_Get_only_once
为真并且结果已被检索过,则抛出future_already_retrieved
异常。_Retrieved
设置为true
。_Maybe_run_deferred_function
来运行可能的延迟任务。这个函数很简单,就是单纯的执行延时任务而已,在讲述 wait
成员函数的时候已经讲完了。std::async
和 std::future
的组合无关,因为如果是 launch::async
模式创建的任务,重写的 _Get_value
是先调用了 _Task.wait();
确保异步任务执行完毕,此处根本无需等待它)_Ty
是默认可构造的,返回结果_Result
。_Result._Held_value
。_Result
是通过执行 _Call_immediate
函数,然后 _Call_immediate
再执行 _Set_value
,_Set_value
再执行 _Emplace_result
,_Emplace_result
再执行 _Emplace_result
获取到我们执行任务的值的。以 Ty
的偏特化为例:
// _Packaged_state
+void _Call_immediate(_ArgTypes... _Args) {
+ _TRY_BEGIN
+ // 调用函数对象并捕获异常 传递返回值
+ this->_Set_value(_Fn(_STD forward<_ArgTypes>(_Args)...), false);
+ _CATCH_ALL
+ // 函数对象抛出异常就记录
+ this->_Set_exception(_STD current_exception(), false);
+ _CATCH_END
+}
+
+// _Asscoiated_state
+void _Set_value(const _Ty& _Val, bool _At_thread_exit) { // store a result
+ unique_lock<mutex> _Lock(_Mtx);
+ _Set_value_raw(_Val, &_Lock, _At_thread_exit);
+}
+void _Set_value_raw(const _Ty& _Val, unique_lock<mutex>* _Lock, bool _At_thread_exit) {
+ // store a result while inside a locked block
+ if (_Already_has_stored_result()) {
+ _Throw_future_error2(future_errc::promise_already_satisfied);
+ }
+
+ _Emplace_result(_Val);
+ _Do_notify(_Lock, _At_thread_exit);
+}
+template <class _Ty2>
+void _Emplace_result(_Ty2&& _Val) {
+ // TRANSITION, incorrectly assigns _Result when _Ty is default constructible
+ if constexpr (is_default_constructible_v<_Ty>) {
+ _Result = _STD forward<_Ty2>(_Val); // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ } else {
+ ::new (static_cast<void*>(_STD addressof(_Result._Held_value))) _Ty(_STD forward<_Ty2>(_Val));
+ _Has_stored_result = true;
+ }
+}
好了,到此也就可以了。
你不会期待我们将每一个成员函数都分析一遍吧?首先是没有必要,其次是篇幅限制。
std::future
的继承关系让人感到头疼,但是如果耐心的看了一遍,全部搞明白了继承关系, std::async
如何创建的 std::future
也就没有问题了。
其实各位不用着急完全理解,可以慢慢看,至少有许多的显著的信息,比如:
sttd::future
的很多部分,如 get()
成员函数实现中,实际使用了虚函数。std::async
创建 std::future
对象中,内部其实也有父类指针指向子类对象,以及多态调用。std::async
的非延迟执行策略,使用到了自家的 PPL 库。std::async
策略实现并不符合标准,不区分 launch::async | launch::deferred
和 launch::async
。std::future
内部使用到了互斥量、条件变量、异常指针等设施。和之前一样的,我们以 MSVC STL 的实现进行讲解。
\\nstd::future
,即未来体,是用来管理一个共享状态的类模板,用于等待关联任务的完成并获取其返回值。它自身不包含状态,需要通过如 std::async
之类的函数进行初始化。std::async
函数模板返回一个已经初始化且具有共享状态的 std::future
对象。
因此,所有操作的开始应从 std::async
开始讲述。
需要注意的是,它们的实现彼此之间会共用不少设施,在讲述 std::async
源码的时候,对于 std::future
的内容同样重要。
本章节主要内容:
多线程共享数据的问题
使用互斥量保护共享数据
保护共享数据的其它方案
有关线程安全的其它问题
在上一章内容,我们对于线程的基本使用和管理,可以说已经比较了解了,甚至深入阅读了部分的 std::thread
源码。所以如果你好好学习了上一章,本章也完全不用担心。
我们本章,就要开始聊共享数据的那些事。
在多线程的情况下,每个线程都抢着完成自己的任务。在大多数情况下,即使会改变执行顺序,也是良性竞争,这是无所谓的。比如两个线程都要往标准输出输出一段字符,谁先谁后并不会有什么太大影响。
void f() { std::cout << "❤️\\n"; }
+void f2() { std::cout << "😢\\n"; }
+
+int main(){
+ std::thread t{ f };
+ std::thread t2{ f2 };
+ t.join();
+ t2.join();
+}
std::cout
的 operator<< 调用是线程安全的,不会被打断。即:同步的 C++ 流保证是线程安全的(从多个线程输出的单独字符可能交错,但无数据竞争)
只有在涉及多线程读写相同共享数据的时候,才会导致“恶性的条件竞争”。
std::vector<int>v;
+
+void f() { v.emplace_back(1); }
+void f2() { v.erase(v.begin()); }
+
+int main() {
+ std::thread t{ f };
+ std::thread t2{ f2 };
+ t.join();
+ t2.join();
+ std::cout << v.size() << '\\n';
+}
比如这段代码就是典型的恶性条件竞争,两个线程共享一个 vector
,并对它进行修改。可能导致许多问题,比如 f2
先执行,此时 vector
还没有元素,导致抛出异常。又或者 f
执行了一半,调用了 f2()
,等等。
当然了,也有可能先执行 f,然后执行 f2,最后打印了 0,程序老老实实执行完毕。
但是我们显然不能寄希望于这种操作系统的调度。
而且即使不是一个添加元素,一个删除元素,全是 emplace_back
添加元素,也一样会有问题,由于 std::vector 不是线程安全的容器,因此当多个线程同时访问并修改 v 时,可能会发生未定义的行为。具体来说,当两个线程同时尝试向 v 中添加元素时,但是 emplace_back
函数却是可以被打断的,执行了一半,又去执行另一个线程。可能会导致数据竞争,从而引发未定义的结果。
当某个表达式的求值写入某个内存位置,而另一求值读或修改同一内存位置时,称这些表达式冲突。拥有两个冲突的求值的程序就有数据竞争,除非
- 两个求值都在同一线程上,或者在同一信号处理函数中执行,或
- 两个冲突的求值都是原子操作(见 std::atomic),或
- 一个冲突的求值发生早于 另一个(见 std::memory_order)
如果出现数据竞争,那么程序的行为未定义。
标量类型等都同理,有数据竞争,未定义行为:
int cnt = 0;
+auto f = [&]{cnt++;};
+std::thread t1{f}, t2{f}, t3{f}; // 未定义行为
互斥量(Mutex),又常被称为互斥锁、互斥体(或者直接被称作“锁”),是一种用来保护临界区[1]的特殊对象,其相当于实现了一个公共的“标志位”。它可以处于锁定(locked)状态,也可以处于解锁(unlocked)状态:
如果互斥量是锁定的,通常说某个特定的线程正持有这个锁。
如果没有线程持有这个互斥量,那么这个互斥量就处于解锁状态。
概念从来不是我们的重点,用一段对比代码为你直观的展示互斥量的作用:
void f() {
+ std::cout << std::this_thread::get_id() << '\\n';
+}
+
+int main() {
+ std::vector<std::thread> threads;
+ for (std::size_t i = 0; i < 10; ++i)
+ threads.emplace_back(f);
+
+ for (auto& thread : threads)
+ thread.join();
+}
这段代码你多次运行它会得到毫无规律和格式的结果,我们可以使用互斥量解决这个问题:
#include <mutex> // 必要标头
+std::mutex m;
+
+void f() {
+ m.lock();
+ std::cout << std::this_thread::get_id() << '\\n';
+ m.unlock();
+}
+
+int main() {
+ std::vector<std::thread>threads;
+ for (std::size_t i = 0; i < 10; ++i)
+ threads.emplace_back(f);
+
+ for (auto& thread : threads)
+ thread.join();
+}
当多个线程执行函数 f
的时候,只有一个线程能成功调用 lock()
给互斥量上锁,其他所有的线程 lock()
的调用将阻塞执行,直至获得锁。第一个调用 lock()
的线程得以继续往下执行,执行我们的 std::cout
输出语句,不会有任何其他的线程打断这个操作。直到线程执行 unlock()
,就解锁了互斥量。
那么其他线程此时也就能再有一个成功调用 lock
...
至于到底哪个线程才会成功调用,这个是由操作系统调度决定的。
看一遍描述就可以了,简而言之,被 lock()
和 unlock()
包含在其中的代码是线程安全的,同一时间只有一个线程执行,不会被其它线程的执行所打断。
std::lock_guard
不过一般不推荐这样显式的 lock()
与 unlock()
,我们可以使用 C++11 标准库引入的“管理类” std::lock_guard
:
void f() {
+ std::lock_guard<std::mutex> lc{ m };
+ std::cout << std::this_thread::get_id() << '\\n';
+}
那么问题来了,std::lock_guard
是如何做到的呢?它是怎么实现的呢?首先顾名思义,这是一个“管理类”模板,用来管理互斥量的上锁与解锁,我们来看它在 MSVC STL 的实现:
_EXPORT_STD template <class _Mutex>
+class _NODISCARD_LOCK lock_guard { // class with destructor that unlocks a mutex
+public:
+ using mutex_type = _Mutex;
+
+ explicit lock_guard(_Mutex& _Mtx) : _MyMutex(_Mtx) { // construct and lock
+ _MyMutex.lock();
+ }
+
+ lock_guard(_Mutex& _Mtx, adopt_lock_t) noexcept // strengthened
+ : _MyMutex(_Mtx) {} // construct but don't lock
+
+ ~lock_guard() noexcept {
+ _MyMutex.unlock();
+ }
+
+ lock_guard(const lock_guard&) = delete;
+ lock_guard& operator=(const lock_guard&) = delete;
+
+private:
+ _Mutex& _MyMutex;
+};
这段代码极其简单,首先管理类,自然不可移动不可复制,我们定义复制构造与复制赋值为弃置函数,同时阻止了移动等函数的隐式定义。
它只保有一个私有数据成员,一个引用,用来引用互斥量。
构造函数中初始化这个引用,同时上锁,析构函数中解锁,这是一个非常典型的 RAII
式的管理。
同时它还提供一个有额外std::adopt_lock_t
参数的构造函数 ,如果使用这个构造函数,则构造函数不会上锁。
所以有的时候你可能会看到一些这样的代码:
void f(){
+ //code..
+ {
+ std::lock_guard<std::mutex> lc{ m };
+ // 涉及共享资源的修改的代码...
+ }
+ //code..
+}
使用 {}
创建了一个块作用域,限制了对象 lc
的生存期,进入作用域构造 lock_guard
的时候上锁(lock),离开作用域析构的时候解锁(unlock)。
“粒度”通常用于描述锁定的范围大小,较小的粒度意味着锁定的范围更小,因此有更好的性能和更少的竞争。
我们举一个例子:
std::mutex m;
+
+void add_to_list(int n, std::list<int>& list) {
+ std::vector<int> numbers(n + 1);
+ std::iota(numbers.begin(), numbers.end(), 0);
+ int sum = std::accumulate(numbers.begin(), numbers.end(), 0);
+
+ {
+ std::lock_guard<std::mutex> lc{ m };
+ list.push_back(sum);
+ }
+}
+void print_list(const std::list<int>& list){
+ std::lock_guard<std::mutex> lc{ m };
+ for(const auto& i : list){
+ std::cout << i << ' ';
+ }
+ std::cout << '\\n';
+}
std::list<int> list;
+std::thread t1{ add_to_list,i,std::ref(list) };
+std::thread t2{ add_to_list,i,std::ref(list) };
+std::thread t3{ print_list,std::cref(list) };
+std::thread t4{ print_list,std::cref(list) };
+t1.join();
+t2.join();
+t3.join();
+t4.join();
完整代码测试。
先看 add_to_list
,只有 list.push_back(sum)
涉及到了对共享数据的修改,需要进行保护,我们用 {}
包起来了。
假设有线程 A、B执行函数 add_to_list()
:线程 A 中的 numbers、sum 与线程 B 中的,不是同一个,希望大家分清楚,自然不存在数据竞争,也不需要上锁。线程 A、B执行完了前面求 0-n
的计算,只有一个线程能在 lock_guard
的构造函数中成功调用 lock() 给互斥量上锁。假设线程 A 成功调用 lock(),那么线程 B 的 lock() 调用就阻塞了,必须等待线程 A 执行完里面的代码,然后作用域结束,调用 lock_guard
的析构函数,解锁 unlock(),此时线程 B 就可以进去执行了,避免了数据竞争,不存在一个对象同时被多个线程修改。
函数 print_list()
就更简单了,打印 list
,给整个函数上锁,同一时刻只能有一个线程执行。
我们的使用代码是多个线程执行这两个函数,两个函数共享了一个锁,这样确保了当执行函数 print_list()
打印的时候,list 的状态是确定的。打印函数 print_list
和 add_to_list
函数的修改操作同一时间只能有一个线程在执行。print_list()
不可能看到正在被add_to_list()
修改的 list。
至于到底哪个函数哪个线程会先执行,执行多少次,这些都由操作系统调度决定,也完全有可能连续 4 次都是执行函数 print_list
的线程成功调用 lock
,会打印出了一样的值,这都很正常。
C++17 添加了一个新的特性,类模板实参推导, std::lock_guard
可以根据传入的参数自行推导,而不需要写明模板类型参数:
std::mutex m;
+std::lock_guard lc{ m }; // std::lock_guard<std::mutex>
并且 C++17 还引入了一个新的“管理类”:std::scoped_lock
,它相较于 lock_guard
的区别在于,它可以管理多个互斥量。不过对于处理一个互斥量的情况,它和 lock_guard
几乎完全相同。
std::mutex m;
+std::scoped_lock lc{ m }; // std::scoped_lock<std::mutex>
我们在后续管理多个互斥量,会详细了解这个类。
try_lock
try_lock
是互斥量中的一种尝试上锁的方式。与常规的 lock
不同,try_lock
会尝试上锁,但如果锁已经被其他线程占用,则不会阻塞当前线程,而是立即返回。
它的返回类型是 bool
,如果上锁成功就返回 true
,失败就返回 false
。
这种方法在多线程编程中很有用,特别是在需要保护临界区的同时,又不想线程因为等待锁而阻塞的情况下。
std::mutex mtx;
+
+void thread_function(int id) {
+ // 尝试加锁
+ if (mtx.try_lock()) {
+ std::cout << "线程:" << id << " 获得锁" << std::endl;
+ // 临界区代码
+ std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 模拟临界区操作
+ mtx.unlock(); // 解锁
+ std::cout << "线程:" << id << " 释放锁" << std::endl;
+ } else {
+ std::cout << "线程:" << id << " 获取锁失败 处理步骤" << std::endl;
+ }
+}
如果有两个线程运行这段代码,必然有一个线程无法成功上锁,要走 else 的分支。
std::thread t1(thread_function, 1);
+std::thread t2(thread_function, 2);
+
+t1.join();
+t2.join();
可能的运行结果:
线程:1 获得锁
+线程:2 获取锁失败 处理步骤
+线程:1 释放锁
互斥量主要也就是为了保护共享数据,上一节的使用互斥量也已经为各位展示了一些。
然而使用互斥量来保护共享数据也并不是在函数中加上一个 std::lock_guard
就万事大吉了。有的时候只需要一个指针或者引用,就能让这种保护形同虚设。
class Data{
+ int a{};
+ std::string b{};
+public:
+ void do_something(){
+ // 修改数据成员等...
+ }
+};
+
+class Data_wrapper{
+ Data data;
+ std::mutex m;
+public:
+ template<class Func>
+ void process_data(Func func){
+ std::lock_guard<std::mutex> lc{m};
+ func(data); // 受保护数据传递给函数
+ }
+};
+
+Data* p = nullptr;
+
+void malicious_function(Data& protected_data){
+ p = &protected_data; // 受保护的数据被传递到外部
+}
+
+Data_wrapper d;
+
+void foo(){
+ d.process_data(malicious_function); // 传递了一个恶意的函数
+ p->do_something(); // 在无保护的情况下访问保护数据
+}
成员函数模板 process_data
看起来一点问题也没有,使用 std::lock_guard
对数据做了保护,但是调用方传递了 malicious_function
这样一个恶意的函数,使受保护数据传递给外部,可以在没有被互斥量保护的情况下调用 do_something()
。
我们传递的函数就不该是涉及外部副作用的,就应该是单纯的在受互斥量保护的情况下老老实实调用 do_something()
操作受保护的数据。
process_data
的确算是没问题,用户非要做这些事情也是防不住的,我们只是告诉各位可能的情况。
试想一下,有一个玩具,这个玩具有两个部分,必须同时拿到两部分才能玩。比如一个遥控汽车,需要遥控器和玩具车才能玩。有两个小孩,他们都想玩这个玩具。当其中一个小孩拿到了遥控器和玩具车时,就可以尽情玩耍。当另一个小孩也想玩,他就得等待另一个小孩玩完才行。再试想,遥控器和玩具车被放在两个不同的地方,并且两个小孩都想要玩,并且一个拿到了遥控器,另一个拿到了玩具车。问题就出现了,除非其中一个孩子决定让另一个先玩,他把自己的那个部分给另一个小孩。但如果他们都不愿意,那么这个遥控汽车就谁都没有办法玩。
我们当然不在乎小孩抢玩具,我们要聊的是线程对锁的竞争:两个线程需要对它们所有的互斥量做一些操作,其中每个线程都有一个互斥量,且等待另一个线程的互斥量解锁。因为它们都在等待对方释放互斥量,没有线程工作。 这种情况就是死锁。
避免死锁的一般建议是让两个互斥量以相同的顺序上锁,总在互斥量 B 之前锁住互斥量 A,就通常不会死锁。反面示例
std::mutex m1,m2;
+std::size_t n{};
+
+void f(){
+ std::lock_guard<std::mutex> lc1{ m1 };
+ std::lock_guard<std::mutex> lc2{ m2 };
+ ++n;
+}
+void f2() {
+ std::lock_guard<std::mutex> lc1{ m2 };
+ std::lock_guard<std::mutex> lc2{ m1 };
+ ++n;
+}
f
与 f2
因为互斥量上锁顺序不同,就有死锁风险。函数 f
先锁定 m1
,然后再尝试锁定 m2
,而函数 f2
先锁定 m2
再锁定 m1
。如果两个线程同时运行,它们就可能会彼此等待对方释放其所需的锁,从而造成死锁。
简而言之,有可能函数 f 锁定了 m1,函数 f2 锁定了 m2,函数 f 要往下执行,给 m2 上锁,所以在等待 f2 解锁 m2,然而函数 f2 也在等待函数 f 解锁 m1 它才能往下执行。所以死锁。测试代码。
但是有的时候即使固定锁顺序,依旧会产生问题。当有多个互斥量保护同一个类的对象时,对于相同类型的两个不同对象进行数据的交换操作,为了保证数据交换的正确性,就要避免其它线程修改,确保每个对象的互斥量都锁住自己要保护的区域。如果按照前面的的选择一个固定的顺序上锁解锁,则毫无意义,比如:
struct X{
+ X(const std::string& str) :object{ str } {}
+
+ friend void swap(X& lhs, X& rhs);
+private:
+ std::string object;
+ std::mutex m;
+};
+
+void swap(X& lhs, X& rhs) {
+ if (&lhs == &rhs) return;
+ std::lock_guard<std::mutex> lock1{ lhs.m };
+ std::lock_guard<std::mutex> lock2{ rhs.m };
+ swap(lhs.object, rhs.object);
+}
考虑用户调用的时候将参数交换,就会产生死锁:
X a{ "🤣" }, b{ "😅" };
+std::thread t{ [&] {swap(a, b); } }; // 1
+std::thread t2{ [&] {swap(b, a); } }; // 2
1
执行的时候,先上锁 a 的互斥量,再上锁 b 的互斥量。
2
执行的时候,先上锁 b 的互斥量,再上锁 a 的互斥量。
完全可能线程 A 执行 1 的时候上锁了 a 的互斥量,线程 B 执行
2
上锁了 b 的互斥量。线程 A 往下执行需要上锁 b 的互斥量,线程 B 则要上锁 a 的互斥量执行完毕才能解锁,哪个都没办法往下执行,死锁。测试代码。
其实也就是回到了第一个示例的问题。
C++ 标准库有很多办法解决这个问题,可以使用 std::lock
,它能一次性锁住多个互斥量,并且没有死锁风险。修改 swap 代码后如下:
void swap(X& lhs, X& rhs) {
+ if (&lhs == &rhs) return;
+ std::lock(lhs.m, rhs.m); // 给两个互斥量上锁
+ std::lock_guard<std::mutex> lock1{ lhs.m,std::adopt_lock };
+ std::lock_guard<std::mutex> lock2{ rhs.m,std::adopt_lock };
+ swap(lhs.object, rhs.object);
+}
因为前面已经使用了 std::lock
上锁,所以后的 std::lock_guard
构造都额外传递了一个 std::adopt_lock
参数,让其选择到不上锁的构造函数。函数退出也能正常解锁。
std::lock
给 lhs.m
或 rhs.m
上锁时若抛出异常,则在重抛前对任何已锁的对象调用 unlock()
解锁,也就是 std::lock
要么将互斥量都上锁,要么一个都不锁。
C++17 新增了 std::scoped_lock
,提供此函数的 RAII 包装,通常它比裸调用 std::lock
更好。
所以我们前面的代码可以改写为:
void swap(X& lhs, X& rhs) {
+ if (&lhs == &rhs) return;
+ std::scoped_lock guard{ lhs.m,rhs.m };
+ swap(lhs.object, rhs.object);
+}
使用 std::scoped_lock
可以将所有 std::lock
替换掉,减少错误发生。
然而它们的帮助都是有限的,一切最终都是依靠开发者使用与管理。
死锁是多线程编程中令人相当头疼的问题,并且死锁经常是不可预见,甚至难以复现,因为在大部分时间里,程序都能正常完成工作。我们可以通过一些简单的规则,约束开发者的行为,帮助写出“无死锁”的代码。
避免嵌套锁
线程获取一个锁时,就别再获取第二个锁。每个线程只持有一个锁,自然不会产生死锁。如果必须要获取多个锁,使用 std::lock
。
避免在持有锁时调用外部代码
这个建议是很简单的:因为代码是外部提供的,所以没办法确定外部要做什么。外部程序可能做任何事情,包括获取锁。在持有锁的情况下,如果用外部代码要获取一个锁,就会违反第一个指导意见,并造成死锁(有时这是无法避免的)。当写通用代码时(比如保护共享数据中的 Date
类)。这不是接口设计者可以处理的,只能寄希望于调用方传递的代码是能正常执行的。
使用固定顺序获取锁
如同第一个示例那样,固定的顺序上锁就不存在问题。
std::unique_lock
灵活的锁std::unique_lock
是 C++11 引入的一种通用互斥包装器,它相比于 std::lock_guard
更加的灵活。当然,它也更加的复杂,尤其它还可以与我们下一章要讲的条件变量一起使用。使用它可以将之前使用 std::lock_guard
的 swap
改写一下:
void swap(X& lhs, X& rhs) {
+ if (&lhs == &rhs) return;
+ std::unique_lock<std::mutex> lock1{ lhs.m, std::defer_lock };
+ std::unique_lock<std::mutex> lock2{ rhs.m, std::defer_lock };
+ std::lock(lock1, lock2);
+ swap(lhs.object, rhs.object);
+}
解释这段代码最简单的方式就是直接展示标准库的源码,首先,我们要了解 std::defer_lock
是“不获得互斥体的所有权”。没有所有权自然构造函数就不会上锁,但不止如此。我们还要先知道 std::unique_lock 保有的数据成员(都以 MSVC STL 为例):
private:
+ _Mutex* _Pmtx = nullptr;
+ bool _Owns = false;
如你所见很简单,一个互斥量的指针,还有一个就是表示对象是否拥有互斥量所有权的 bool 类型的对象 _Owns
了。我们前面代码会调用构造函数:
unique_lock(_Mutex& _Mtx, defer_lock_t) noexcept
+ : _Pmtx(_STD addressof(_Mtx)), _Owns(false) {} // construct but don't lock
如你所见,只是初始化了数据成员而已,注意,这个构造函数没有给互斥量上锁,且 _Owns
为 false
表示没有互斥量所有权。并且 std::unique_lock
是有 lock()
、try_lock()
、unlock()
成员函数的,所以可以直接传递给 std::lock
、 进行调用。这里还需要提一下 lock()
成员函数的代码:
void lock() { // lock the mutex
+ _Validate();
+ _Pmtx->lock();
+ _Owns = true;
+}
如你所见,正常上锁,并且把 _Owns
设置为 true
,即表示当前对象拥有互斥量的所有权。那么接下来看析构函数:
~unique_lock() noexcept {
+ if (_Owns) {
+ _Pmtx->unlock();
+ }
+}
必须得是当前对象拥有互斥量的所有权析构函数才会调用 unlock() 解锁互斥量。我们的代码因为调用了 lock
,所以 _Owns
设置为 true
,函数结束对象析构的时候会解锁互斥量。
设计挺奇怪的对吧,这个所有权语义。其实上面的代码还不够简单直接,我们再举个例子:
std::mutex m;
+
+int main() {
+ std::unique_lock<std::mutex> lock{ m,std::adopt_lock };
+ lock.lock();
+}
这段代码运行会抛出异常,原因很简单,因为 std::adopt_lock
只是不上锁,但是有所有权,即 _Owns
设置为 true
了,当运行 lock()
成员函数的时候,调用了 _Validate()
进行检测,也就是:
void _Validate() const { // check if the mutex can be locked
+ if (!_Pmtx) {
+ _Throw_system_error(errc::operation_not_permitted);
+ }
+
+ if (_Owns) {
+ _Throw_system_error(errc::resource_deadlock_would_occur);
+ }
+}
满足第二个 if,因为 _Owns
为 true
所以抛出异常,别的标准库也都有类似设计。很诡异的设计对吧,正常。除非我们写成:
lock.mutex()->lock();
也就是说 std::unique_lock
要想调用 lock()
成员函数,必须是当前没有所有权。
所以正常的用法其实是,先上锁了互斥量,然后传递 std::adopt_lock
构造 std::unique_lock
对象表示拥有互斥量的所有权,即可在析构的时候正常解锁。如下:
std::mutex m;
+
+int main() {
+ m.lock();
+ std::unique_lock<std::mutex> lock { m,std::adopt_lock };
+}
简而言之:
std::defer_lock
构造函数不上锁,要求构造之后上锁std::adopt_lock
构造函数不上锁,要求在构造之前互斥量上锁我们前面提到了 std::unique_lock
更加灵活,那么灵活在哪?很简单,它拥有 lock()
和 unlock()
成员函数,所以我们能写出如下代码:
void f() {
+ //code..
+
+ std::unique_lock<std::mutex> lock{ m };
+
+ // 涉及共享资源的修改的代码...
+
+ lock.unlock(); // 解锁并释放所有权,析构函数不会再 unlock()
+
+ //code..
+}
而不是像之前 std::lock_guard
一样使用 {}
。
另外再聊一聊开销吧,其实倒也还好,多了一个 bool
,内存对齐,x64 环境也就是 16
字节。这都不是最重要的,主要是复杂性和需求,通常建议优先 std::lock_guard
,当它无法满足你的需求或者显得代码非常繁琐,那么可以考虑使用 std::unique_lock
。
首先我们要明白,互斥量满足互斥体 (Mutex)的要求,不可复制不可移动。所谓的在不同作用域传递互斥量,其实只是传递了它们的指针或者引用罢了。可以利用各种类来进行传递,比如前面提到的 std::unique_lock
。
std::unique_lock
可以获取互斥量的所有权,而互斥量的所有权可以通过移动操作转移给其他的 std::unique_lock
对象。有些时候,这种转移(就是调用移动构造)是自动发生的,比如当函数返回 std::unique_lock
对象。另一种情况就是得显式使用 std::move
。
请勿对移动语义和转移所有权抱有错误的幻想,我们说的无非是调用
std::unique_lock
的移动构造罢了:_NODISCARD_CTOR_LOCK unique_lock(unique_lock&& _Other) noexcept : _Pmtx(_Other._Pmtx), _Owns(_Other._Owns) { + _Other._Pmtx = nullptr; + _Other._Owns = false; +}
将数据成员赋给新对象,原来的置空,这就是所谓的 “所有权”转移,切勿被词语迷惑。
std::unique_lock
是只能移动不可复制的类,它移动即标志其管理的互斥量的所有权转移了。
一种可能的使用是允许函数去锁住一个互斥量,并将互斥量的所有权转移到调用者上,所以调用者可以在这个锁保护的范围内执行代码。
std::unique_lock<std::mutex> get_lock(){
+ extern std::mutex some_mutex;
+ std::unique_lock<std::mutex> lk{ some_mutex };
+ return lk;
+}
+void process_data(){
+ std::unique_lock<std::mutex> lk{ get_lock() };
+ // 执行一些任务...
+}
return lk
这里会调用移动构造,将互斥量的所有权转移给调用方, process_data
函数结束的时候会解锁互斥量。
我相信你可能对 extern std::mutex some_mutex
有疑问,其实不用感到奇怪,这是一个互斥量的声明,可能别的翻译单元(或 dll 等)有它的定义,成功链接上。我们前面也说了:“所谓的在不同作用域传递互斥量,其实只是传递了它们的指针或者引用罢了”,所以要特别注意互斥量的生存期。
extern 说明符只能搭配变量声明和函数声明(除了类成员或函数形参)。它指定外部链接,而且技术上不影响存储期,但它不能用来定义自动存储期的对象,故所有 extern 对象都具有静态或线程存储期。
如果你简单写一个 std::mutex some_mutex
那么函数 process_data
中的 lk
会持有一个悬垂指针。
举一个使用
extern std::mutex
的完整运行示例。当然,其实理论上你new std::mutex
也是完全可行...... 🤣🤣
std::unique_lock
是灵活的,同样允许在对象销毁之前就解锁互斥量,调用 unlock()
成员函数即可,不再强调。
保护共享数据并非必须使用互斥量,互斥量只是其中一种常见的方式而已,对于一些特殊的场景,也有专门的保护方式,比如对于共享数据的初始化过程的保护。我们通常就不会用互斥量,这会造成很多的额外开销。
我们不想为各位介绍其它乱七八糟的各种保护初始化的方式,我们只介绍三种:双检锁(错误)、使用 std::call_once
、静态局部变量初始化从 C++11 开始是线程安全。
双检锁(错误)线程不安全
void f(){
+ if(!ptr){ // 1
+ std::lock_guard<std::mutex> lk{ m };
+ if(!ptr){ // 2
+ ptr.reset(new some); // 3
+ }
+ }
+ ptr->do_something(); // 4
+}
① 是查看指针是否为空,空才需要初始化,才需要获取锁。指针为空,当获取锁后会再检查一次指针②(这就是双重检查),避免另一线程在第一次检查后再做初始化,并且让当前线程获取锁。
然而这显然没用,因为有潜在的条件竞争。未被锁保护的读取操作①没有与其他线程里被锁保护的写入操作③进行同步,因此就会产生条件竞争。
简而言之:一个线程知道另一个线程已经在执行③,但是此时还没有创建 some 对象,而只是分配内存对指针写入。那么这个线程在①的时候就不会进入,直接执行了 ptr->do_something()
④,得不到正确的结果,因为对象还没构造。
如果你觉得难以理解,那就记住
ptr.reset(new some);
并非是不可打断不可交换的固定指令。这种错误写法在一些单例中也非常的常见。如果你的同事或上司写出此代码,一般不建议指出,因为不见得你能教会他们,不要“没事找事”,只要不影响自己即可。
C++ 标准委员会也认为处理此问题很重要,所以标准库提供了 std::call_once
和 std::once_flag
来处理这种情况。比起锁住互斥量并显式检查指针,每个线程只需要使用 std::call_once
就可以。使用 std::call_once
比显式使用互斥量消耗的资源更少,特别是当初始化完成之后。
std::shared_ptr<some> ptr;
+std::once_flag resource_flag;
+
+void init_resource(){
+ ptr.reset(new some);
+}
+
+void foo(){
+ std::call_once(resource_flag, init_resource); // 线程安全的一次初始化
+ ptr->do_something();
+}
以上代码 std::once_flag
对象是全局命名空间作用域声明,如果你有需要,它也可以是类的成员。用于搭配 std::call_once
使用,保证线程安全的一次初始化。std::call_once
只需要接受可调用 (Callable)对象即可,也不要求一定是函数。
“初始化”,那自然是只有一次。但是
std::call_once
也有一些例外情况(比如异常)会让传入的可调用对象被多次调用,即“多次”初始化:std::once_flag flag; +int n = 0; + +void f(){ + std::call_once(flag, [] { + ++n; + std::cout << "第" << n << "次调用\\n"; + throw std::runtime_error("异常"); + }); +} + +int main(){ + try{ + f(); + } + catch (std::exception&){} + + try{ + f(); + } + catch (std::exception&){} +}
测试链接。正常情况会保证传入的可调用对象只调用一次,即初始化只有一次。异常之类的是例外。
这种行为很合理,因为异常代表操作失败,需要进行回溯和重置状态,符合语义和设计。
静态局部变量初始化在 C++11 是线程安全
class my_class;
+inline my_class& get_my_class_instance(){
+ static my_class instance; // 线程安全的初始化过程 初始化严格发生一次
+ return instance;
+}
即使多个线程同时访问 get_my_class_instance
函数,也只有一个线程会执行 instance 的初始化,其它线程会等待初始化完成。这种实现方式是线程安全的,不用担心数据竞争。此方式也在单例中多见,被称作“Meyers Singleton”单例,是简单合理的做法。
其实还有不少其他的做法或者反例,但是觉得没必要再聊了,这些已经足够了,再多下去就冗余了。且本文不是详尽的文档,而是“教程”。
试想一下,你有一个数据结构存储了用户的设置信息,每次用户打开程序的时候,都要进行读取,且运行时很多地方都依赖这个数据结构需要读取,所以为了效率,我们使用了多线程读写。这个数据结构很少进行改变,而我们知道,多线程读取,是没有数据竞争的,是安全的,但是有些时候又不可避免的有修改和读取都要工作的时候,所以依然必须得使用互斥量进行保护。
然而使用 std::mutex
的开销是过大的,它不管有没有发生数据竞争(也就是就算全是读的情况)也必须是老老实实上锁解锁,只有一个线程可以运行。如果你学过其它语言或者操作系统,相信这个时候就已经想到了:“读写锁”。
C++ 标准库自然为我们提供了: std::shared_timed_mutex
(C++14)、 std::shared_mutex
(C++17)。它们的区别简单来说,前者支持更多的操作方式,后者有更高的性能优势。
std::shared_mutex
同样支持 std::lock_guard
、std::unique_lock
。和 std::mutex
做的一样,保证写线程的独占访问。而那些无需修改数据结构的读线程,可以使用 std::shared_lock<std::shared_mutex>
获取访问权,多个线程可以一起读取。
class Settings {
+private:
+ std::map<std::string, std::string> data_;
+ mutable std::shared_mutex mutex_; // “M&M 规则”:mutable 与 mutex 一起出现
+
+public:
+ void set(const std::string& key, const std::string& value) {
+ std::lock_guard<std::shared_mutex> lock{ mutex_ };
+ data_[key] = value;
+ }
+
+ std::string get(const std::string& key) const {
+ std::shared_lock<std::shared_mutex> lock(mutex_);
+ auto it = data_.find(key);
+ return (it != data_.end()) ? it->second : ""; // 如果没有找到键返回空字符串
+ }
+};
std::shared_timed_mutex
具有 std::shared_mutex
的所有功能,并且额外支持超时功能。所以以上代码可以随意更换这两个互斥量。
std::recursive_mutex
线程对已经上锁的 std::mutex
再次上锁是错误的,这是未定义行为。然而在某些情况下,一个线程会尝试在释放一个互斥量前多次获取,所以提供了std::recursive_mutex
。
std::recursive_mutex
是 C++ 标准库提供的一种互斥量类型,它允许同一线程多次锁定同一个互斥量,而不会造成死锁。当同一线程多次对同一个 std::recursive_mutex
进行锁定时,只有在解锁与锁定次数相匹配时,互斥量才会真正释放。但它并不影响不同线程对同一个互斥量进行锁定的情况。不同线程对同一个互斥量进行锁定时,会按照互斥量的规则进行阻塞,
#include <iostream>
+#include <thread>
+#include <mutex>
+
+std::recursive_mutex mtx;
+
+void recursive_function(int count) {
+ // 递归函数,每次递归都会锁定互斥量
+ mtx.lock();
+ std::cout << "Locked by thread: " << std::this_thread::get_id() << ", count: " << count << std::endl;
+ if (count > 0) {
+ recursive_function(count - 1); // 递归调用
+ }
+ mtx.unlock(); // 解锁互斥量
+}
+
+int main() {
+ std::thread t1(recursive_function, 3);
+ std::thread t2(recursive_function, 2);
+
+ t1.join();
+ t2.join();
+}
运行测试。
lock
:线程可以在递归互斥体上重复调用 lock
。在线程调用 unlock
匹配次数后,所有权才会得到释放。
unlock
:若所有权层数为 1(此线程对 lock() 的调用恰好比 unlock()
多一次 )则解锁互斥量,否则将所有权层数减少 1。
我们重点的强调了一下这两个成员函数的这个概念,其实也很简单,总而言之就是 unlock
必须和 lock
的调用次数一样,才会真正解锁互斥量。
同样的,我们也可以使用 std::lock_guard
、std::unique_lock
帮我们管理 std::recursive_mutex
,而非显式调用 lock
与 unlock
:
void recursive_function(int count) {
+ std::lock_guard<std::recursive_mutex> lc{ mtx };
+ std::cout << "Locked by thread: " << std::this_thread::get_id() << ", count: " << count << std::endl;
+ if (count > 0) {
+ recursive_function(count - 1);
+ }
+}
运行测试。
new
、delete
是线程安全的吗?如果你的标准达到 C++11,要求下列函数是线程安全的:
new
运算符和 delete
运算符的库版本new
运算符和 delete
运算符的用户替换版本所以以下函数在多线程运行是线程安全的:
void f(){
+ T* p = new T{};
+ delete p;
+}
内存分配、释放操作是线程安全,构造和析构不涉及共享资源。而局部对象 p
对于每个线程来说是独立的。换句话说,每个线程都有其自己的 p
对象实例,因此它们不会共享同一个对象,自然没有数据竞争。
如果 p
是全局对象(或者外部的,只要可被多个线程读写),多个线程同时对其进行访问和修改时,就可能会导致数据竞争和未定义行为。因此,确保全局对象的线程安全访问通常需要额外的同步措施,比如互斥量或原子操作。
T* p = nullptr;
+void f(){
+ p = new T{}; // 存在数据竞争
+ delete p;
+}
即使 p
是局部对象,如果构造函数(析构同理)涉及读写共享资源,那么一样存在数据竞争,需要进行额外的同步措施进行保护。
int n = 1;
+
+struct X{
+ X(int v){
+ ::n += v;
+ }
+};
+
+void f(){
+ X* p = new X{ 1 }; // 存在数据竞争
+ delete p;
+}
一个直观的展示是,我们可以在构造函数中使用
std::cout
,看到无序的输出,例子。
值得注意的是,如果是自己重载 operator new
、operator delete
替换了库的全局版本,那么它的线程安全就要我们来保证。
// 全局的 new 运算符,替换了库的版本
+void* operator new (std::size_t count){
+ return ::operator new(count);
+}
以上代码是线程安全的,因为 C++11 保证了 new 运算符的库版本,即 ::operator new
是线程安全的,我们直接调用它自然不成问题。如果你需要更多的操作,就得使用互斥量之类的方式保护了。
总而言之,new
表达式线程安全要考虑三方面:operator new
、构造函数、修改指针。
delete
表达式线程安全考虑两方面:operator delete
、析构函数。
C++ 只保证了 operator new
、operator delete
这两个方面的线程安全(不包括用户定义的),其它方面就得自己保证了。前面的内容也都提到了。
线程存储期(也有人喜欢称作“线程局部存储”)的概念源自操作系统,是一种非常古老的机制,广泛应用于各种编程语言。线程存储期的对象在线程开始时分配,并在线程结束时释放。每个线程拥有自己独立的对象实例,互不干扰。在 C++11中,引入了thread_local
关键字,用于声明具有线程存储期的对象。不少开发者喜欢直接将声明为线程存储期的对象称为:线程变量;也与全局变量、CPU 变量,在一起讨论。
以下是一段示例代码,展示了 thread_local
关键字的使用:
int global_counter = 0;
+thread_local int thread_local_counter = 0;
+
+void print_counters(){
+ std::cout << "global:" << global_counter++ << '\\n';
+ std::cout << "thread_local:" << thread_local_counter++ << '\\n';
+}
+
+int main(){
+ std::thread{ print_counters }.join();
+ std::thread{ print_counters }.join();
+}
运行结果:
global:0
+thread_local:0
+global:1
+thread_local:0
这段代码很好的展示了 thread_local
关键字的使用以及它的作用。每一个线程都有独立的 thread_local_counter
对象,它们不是同一个。
我知道你会有问题:“那么 C++11 之前呢?”那时开发者通常使用 POSIX 线程(Pthreads)或 Win32 线程的接口,或者依赖各家编译器的扩展。例如:
pthread_key_t
和相关的函数( pthread_key_create
、pthread_setspecific
、pthread_getspecific
和pthread_key_delete
)来管理线程局部存储。TlsAlloc
、TlsSetValue
、TlsGetValue
和 TlsFree
来实现线程局部存储。__thread
。__declspec(thread)
。POSIX 与 Win32 接口的就不再介绍了,有兴趣参见我们的链接即可。我们就拿先前的代码改成使用 GCC 与 MSVC 的编译器扩展即可。
__thread int thread_local_counter = 0; // GCC
+__declspec(thread) int thread_local_counter = 0; // MSVC
MSVC 无法使用 GCC 的编译器扩展,GCC 也肯定无法使用 MSVC 的扩展,不过 Clang 编译器可以,它支持 __thread
与 __declspec(thread)
两种。Clang 默认情况就支持 GCC 的编译器扩展,如果要支持 MSVC,需要设置 -fms-extensions
编译选项。
要注意的是,这些扩展并不是标准的 C++ 语言特性,它们的跨平台性和可移植性较差,我们应当使用 C++ 标准的 thread_local
。
了解其它 API 以及编译器扩展有助于理解历史上线程存储期的演进。同时扩展知识面。
注意事项
需要注意的是,在 MSVC 的实现中,如果
std::async
策略为launch::async
,但却并不是每次都创建一个新的线程,而是从线程池获取线程。这意味着无法保证线程局部变量在任务完成时会被销毁。如果线程被回收并用于新的std::async
调用,则旧的线程局部变量仍然存在。因此,建议不要将线程局部变量与std::async
一起使用。文档。虽然还没有讲
std::async
,不过还是可以注意一下这个问题,我们用一个简单的示例为你展示:int n = 0; + +struct X { + ~X() { ++n; } +}; + +thread_local X x{}; + +void use_thread_local_x() { + // 如果不写此弃值表达式,那么在 Gcc 与 Clang 编译此代码,会优化掉 x + (void)x; +} + +int main() { + std::cout << "使用 std::thread: \\n"; + std::thread{ use_thread_local_x }.join(); + std::cout << n << '\\n'; + + std::cout << "使用 std::async: \\n"; + std::async(std::launch::async, use_thread_local_x); + std::cout << n << '\\n'; +}
在不同编译器上的输出结果:
- Linux/Windows GCC、Clang:会输出
1
、2
,因为线程变量x
在每个任务中被正确销毁析构。- Windows MSVC:会输出
1
、1
,因为线程被线程池复用,线程依然活跃,线程变量 x 也就还未释放。
CPU 变量的概念很好理解。就像线程变量为每个线程提供独立的对象实例,互不干扰一样,CPU 变量也是如此。在创建 CPU 变量时,系统上的每个 CPU [2] 都会获得该变量的一个副本。
在 Linux 内核中,从 2.6[3] 版本开始引入了 Per-CPU 变量(Per-CPU variables)功能。Per-CPU 变量是为每个处理器单独分配的变量副本,旨在减少多处理器访问共享数据时的同步开销,提升性能。每个处理器只访问自己的变量副本,不需要进行同步操作,避免了数据竞争,增强了并行处理能力。
在 Windows 内核中,没有直接对应的 Per-CPU 变量机制。
本节是偏向概念的认识,而非实际进行内核编程,C++ 语言层面也并未提供此抽象。理解 CPU 变量的概念对于系统编程和内核开发非常重要。这些概念在面试和技术讨论中常常出现,掌握这些知识不仅有助于应对面试问题,也能提升对多处理器系统性能优化的理解。
在并发编程中,不同的变量有不同的使用场景和特点。以下是局部变量、全局变量、线程变量、CPU变量的对比:
局部变量(不考虑静态局部)
全局变量
线程变量
CPU变量
总而言之,结合实际使用即可,把这四种变量拿出来进行对比,增进理解,加深印象。
本章讨论了多线程的共享数据引发的恶性条件竞争会带来的问题。并说明了可以使用互斥量(std::mutex
)保护共享数据,并且要注意互斥量上锁的“粒度”。C++标准库提供了很多工具,包括管理互斥量的管理类(std::lock_guard
),但是互斥量只能解决它能解决的问题,并且它有自己的问题(死锁)。同时我们讲述了一些避免死锁的方法和技术。还讲了一下互斥量所有权转移。然后讨论了面对不同情况保护共享数据的不同方式,使用 std::call_once()
保护共享数据的初始化过程,使用读写锁(std::shared_mutex
)保护不常更新的数据结构。以及特殊情况可能用到的互斥量 recursive_mutex
,有些人可能喜欢称作:递归锁。然后聊了一下 new
、delete
运算符的库函数实际是线程安全的。最后介绍了一下线程存储期、CPU 变量,和各种变量进行了一个对比。
下一章,我们将开始讲述同步操作,会使用到 Futures、条件变量等设施。
"临界区"指的是一个访问共享资源的程序片段,而这些共享资源又无法同时被多个线程访问的特性。在临界区中,通常会使用同步机制,比如我们要讲的互斥量(Mutex)。 ↩︎
“每个 CPU”,指的是系统中的每个物理处理器或每个逻辑处理器(如果超线程被启用)。 ↩︎
之所以说是“几乎”,是因为局部对象的构造、析构,或其它成员函数也可能修改共享数据、全局状态。如果它们不是线程安全的,同样可能产生数据竞争,例如,某类型 X
的构造函数会自增一个全局变量 n
,那么即使局部对象本身是独立的,但由于构造函数修改了共享数据,依然会产生数据竞争。不过实践中这种情况较少,即使涉及到全局的状态,通常其本身也是线程安全的,例如前文提到的 new
、delete
线程安全的问题。 ↩︎
之所以说“通常”而不是一定,是因为理论上线程变量一样可能产生数据竞争(例如有一个全局的指针指向了一个线程局部变量,其它线程通过这个指针读写线程局部变量而不附加同步,自然会产生数据竞争),只不过实践中通常不会那样做,所以我们不额外提及。 ↩︎
本章节主要内容:
\\n多线程共享数据的问题
\\n使用互斥量保护共享数据
\\n保护共享数据的其它方案
\\n有关线程安全的其它问题
\\n在上一章内容,我们对于线程的基本使用和管理,可以说已经比较了解了,甚至深入阅读了部分的 std::thread
源码。所以如果你好好学习了上一章,本章也完全不用担心。
我们本章,就要开始聊共享数据的那些事。
\\n在多线程的情况下,每个线程都抢着完成自己的任务。在大多数情况下,即使会改变执行顺序,也是良性竞争,这是无所谓的。比如两个线程都要往标准输出输出一段字符,谁先谁后并不会有什么太大影响。
"}');export{y as comp,F as data}; diff --git "a/assets/04\345\220\214\346\255\245\346\223\215\344\275\234.html-rJexZaMy.js" "b/assets/04\345\220\214\346\255\245\346\223\215\344\275\234.html-rJexZaMy.js" new file mode 100644 index 00000000..b5a09f4c --- /dev/null +++ "b/assets/04\345\220\214\346\255\245\346\223\215\344\275\234.html-rJexZaMy.js" @@ -0,0 +1,868 @@ +import{_ as i}from"./plugin-vue_export-helper-DlAUqK2U.js";import{c as a,d as n,o as h}from"./app-Ca-A-iaw.js";const l="/ModernCpp-ConcurrentProgramming-Tutorial/assets/%E8%BF%9B%E5%BA%A6%E6%9D%A1-QdAFSPTh.png",t={};function k(e,s){return h(),a("div",null,s[0]||(s[0]=[n(`"同步操作"是指在计算机科学和信息技术中的一种操作方式,其中不同的任务或操作按顺序执行,一个操作完成后才能开始下一个操作。在多线程编程中,各个任务通常需要通过同步设施进行相互协调和等待,以确保数据的一致性和正确性。
本章的主要内容有:
条件变量
std::future
等待异步任务
在规定时间内等待
Qt 实现异步任务的示例
其它 C++20 同步设施:信号量、闩与屏障
本章将讨论如何使用条件变量等待事件,介绍 future 等标准库设施用作同步操作,使用Qt+CMake 构建一个项目展示多线程的必要性,介绍 C++20 引入的新的同步设施。
假设你正在一辆夜间运行的地铁上,那么你要如何在正确的站点下车呢?
一直不休息,每一站都能知道,这样就不会错过你要下车的站点,但是这会很疲惫。
可以看一下时间,估算一下地铁到达目的地的时间,然后设置一个稍早的闹钟,就休息。这个方法听起来还行,但是你可能被过早的叫醒,甚至估算错误导致坐过站,又或者闹钟没电了睡过站。
事实上最简单的方式是,到站的时候有人或者其它东西能将你叫醒(比如手机的地图,到达设置的位置就提醒)。
这和线程有什么关系呢?其实第一种方法就是在说”忙等待(busy waiting)”也称“自旋“。
bool flag = false;
+std::mutex m;
+
+void wait_for_flag(){
+ std::unique_lock<std::mutex> lk{ m };
+ while (!flag){
+ lk.unlock(); // 1 解锁互斥量
+ lk.lock(); // 2 上锁互斥量
+ }
+}
第二种方法就是加个延时,这种实现进步了很多,减少浪费的执行时间,但很难确定正确的休眠时间。这会影响到程序的行为,在需要快速响应的程序中就意味着丢帧或错过了一个时间片。循环中,休眠②前函数对互斥量解锁①,再休眠结束后再对互斥量上锁,让另外的线程有机会获取锁并设置标识(因为修改函数和等待函数共用一个互斥量)。
void wait_for_flag(){
+ std::unique_lock<std::mutex> lk{ m };
+ while (!flag){
+ lk.unlock(); // 1 解锁互斥量
+ std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 2 休眠
+ lk.lock(); // 3 上锁互斥量
+ }
+}
第三种方式(也是最好的)实际上就是使用条件变量了。通过另一线程触发等待事件的机制是最基本的唤醒方式,这种机制就称为“条件变量”。
C++ 标准库对条件变量有两套实现:std::condition_variable
和 std::condition_variable_any
,这两个实现都包含在 <condition_variable>
头文件中。
condition_variable_any
类是 std::condition_variable
的泛化。相对于只在 std::unique_lock<std::mutex>
上工作的 std::condition_variable
,condition_variable_any
能在任何满足可基本锁定(BasicLockable)要求的锁上工作,所以增加了 _any
后缀。显而易见,这种区分必然是 any
版更加通用但是却有更多的性能开销。所以通常首选 std::condition_variable
。有特殊需求,才会考虑 std::condition_variable_any
。
std::mutex mtx;
+std::condition_variable cv;
+bool arrived = false;
+
+void wait_for_arrival() {
+ std::unique_lock<std::mutex> lck(mtx);
+ cv.wait(lck, []{ return arrived; }); // 等待 arrived 变为 true
+ std::cout << "到达目的地,可以下车了!" << std::endl;
+}
+
+void simulate_arrival() {
+ std::this_thread::sleep_for(std::chrono::seconds(5)); // 模拟地铁到站,假设5秒后到达目的地
+ {
+ std::lock_guard<std::mutex> lck(mtx);
+ arrived = true; // 设置条件变量为 true,表示到达目的地
+ }
+ cv.notify_one(); // 通知等待的线程
+}
std::mutex mtx
: 创建了一个互斥量,用于保护共享数据的访问,确保在多线程环境下的数据同步。
std::condition_variable cv
: 创建了一个条件变量,用于线程间的同步,当条件不满足时,线程可以等待,直到条件满足时被唤醒。
bool arrived = false
: 设置了一个标志位,表示是否到达目的地。
在 wait_for_arrival
函数中:
std::unique_lock<std::mutex> lck(mtx)
: 使用互斥量创建了一个独占锁。
cv.wait(lck, []{ return arrived; })
: 阻塞当前线程,释放(unlock)锁,直到条件被满足。
一旦条件满足,即 arrived
变为 true,并且条件变量 cv
被唤醒(包括虚假唤醒),那么当前线程会重新获取锁(lock),并执行后续的操作。
在 simulate_arrival
函数中:
std::this_thread::sleep_for(std::chrono::seconds(5))
: 模拟地铁到站,暂停当前线程 5 秒。
设置 arrived
为 true,表示到达目的地。
cv.notify_one()
: 唤醒一个等待条件变量的线程。
这样,当 simulate_arrival
函数执行后,arrived
被设置为 true,并且通过 cv.notify_one()
唤醒了等待在条件变量上的线程,从而使得 wait_for_arrival
函数中的等待结束,可以执行后续的操作,即输出提示信息。
条件变量的 wait
成员函数有两个版本,以上代码使用的就是第二个版本,传入了一个谓词。
void wait(std::unique_lock<std::mutex>& lock); // 1
+
+template<class Predicate>
+void wait(std::unique_lock<std::mutex>& lock, Predicate pred); // 2
②等价于:
while (!pred())
+ wait(lock);
第二个版本只是对第一个版本的包装,等待并判断谓词,会调用第一个版本的重载。这可以避免“虚假唤醒(spurious wakeup)”。
条件变量虚假唤醒是指在使用条件变量进行线程同步时,有时候线程可能会在没有收到通知的情况下被唤醒。问题取决于程序和系统的具体实现。解决方法很简单,在循环中等待并判断条件可一并解决。使用 C++ 标准库则没有这个烦恼了。
我们也可以简单看一下 MSVC STL 的源码实现:
void wait(unique_lock<mutex>& _Lck) noexcept {
+ _Cnd_wait(_Mycnd(), _Lck.mutex()->_Mymtx());
+}
+
+template <class _Predicate>
+void wait(unique_lock<mutex>& _Lck, _Predicate _Pred) {
+ while (!_Pred()) {
+ wait(_Lck);
+ }
+}
在本节中,我们介将绍一个更为复杂的示例,以巩固我们对条件变量的学习。为了实现一个线程安全的队列,我们需要考虑以下两个关键点:
当执行 push
操作时,需要确保没有其他线程正在执行 push
或 pop
操作;同样,在执行 pop
操作时,也需要确保没有其他线程正在执行 push
或 pop
操作。
当队列为空时,不应该执行 pop
操作。因此,我们需要使用条件变量来传递一个谓词,以确保在执行 pop
操作时队列不为空。
基于以上思考,我们设计了一个名为 threadsafe_queue
的模板类,如下:
template<typename T>
+class threadsafe_queue {
+ mutable std::mutex m; // 互斥量,用于保护队列操作的独占访问
+ std::condition_variable data_cond; // 条件变量,用于在队列为空时等待
+ std::queue<T> data_queue; // 实际存储数据的队列
+public:
+ threadsafe_queue() {}
+ void push(T new_value) {
+ {
+ std::lock_guard<std::mutex> lk { m };
+ data_queue.push(new_value);
+ }
+ data_cond.notify_one();
+ }
+ // 从队列中弹出元素(阻塞直到队列不为空)
+ void pop(T& value) {
+ std::unique_lock<std::mutex> lk{ m };
+ data_cond.wait(lk, [this] {return !data_queue.empty(); });
+ value = data_queue.front();
+ data_queue.pop();
+ }
+ // 从队列中弹出元素(阻塞直到队列不为空),并返回一个指向弹出元素的 shared_ptr
+ std::shared_ptr<T> pop() {
+ std::unique_lock<std::mutex> lk{ m };
+ data_cond.wait(lk, [this] {return !data_queue.empty(); });
+ std::shared_ptr<T> res { std::make_shared<T>(data_queue.front()) };
+ data_queue.pop();
+ return res;
+ }
+ bool empty()const {
+ std::lock_guard<std::mutex> lk (m);
+ return data_queue.empty();
+ }
+};
请无视我们省略的构造、赋值、交换、try_xx
等操作。以上示例已经足够。
光写好了肯定不够,我们还得测试运行,我们可以写一个经典的:”生产者消费者模型“,也就是一个线程 push
”生产“,一个线程 pop
”消费“。
void producer(threadsafe_queue<int>& q) {
+ for (int i = 0; i < 5; ++i) {
+ q.push(i);
+ }
+}
+void consumer(threadsafe_queue<int>& q) {
+ for (int i = 0; i < 5; ++i) {
+ int value{};
+ q.pop(value);
+ }
+}
两个线程分别运行 producer
与 consumer
,为了观测运行我们可以为 push
与 pop
中增加打印语句:
std::cout << "push:" << new_value << std::endl;
+std::cout << "pop:" << value << std::endl;
可能的运行结果是:
push:0
+pop:0
+push:1
+pop:1
+push:2
+push:3
+push:4
+pop:2
+pop:3
+pop:4
这很正常,到底哪个线程会抢到 CPU 时间片持续运行,是系统调度决定的,我们只需要保证一开始提到的两点就行了:
push
与pop
都只能单独执行;当队列为空时,不执行pop
操作。
我们可以给一个简单的示意图帮助你理解这段运行结果:
初始状态:队列为空
++---+---+---+---+---+
+
+Producer 线程插入元素 0:
++---+---+---+---+---+
+| 0 | | | | |
+
+Consumer 线程弹出元素 0:
++---+---+---+---+---+
+| | | | | |
+
+Producer 线程插入元素 1:
++---+---+---+---+---+
+| 1 | | | | |
+
+Consumer 线程弹出元素 1:
++---+---+---+---+---+
+| | | | | |
+
+Producer 线程插入元素 2:
++---+---+---+---+---+
+| | 2 | | | |
+
+Producer 线程插入元素 3:
++---+---+---+---+---+
+| | 2 | 3 | | |
+
+Producer 线程插入元素 4:
++---+---+---+---+---+
+| | 2 | 3 | 4 | |
+
+Consumer 线程弹出元素 2:
++---+---+---+---+---+
+| | | 3 | 4 | |
+
+Consumer 线程弹出元素 3:
++---+---+---+---+---+
+| | | | 4 | |
+
+Consumer 线程弹出元素 4:
++---+---+---+---+---+
+| | | | | |
+
+队列为空,所有元素已被弹出
到此,也就可以了。
一个常见的场景是:当你的软件完成了主要功能后,领导可能突然要求添加一些竞争对手产品的功能。比如领导看到了人家的设备跑起来总是有一些播报,说明当前的情况,执行的过程,或者报错了也会有提示音说明。于是就想让我们的程序也增加“语音提示”的功能。此时,你需要考虑如何在程序运行到不同状态时添加适当的语音播报,并且确保这些提示音的播放不会影响其他功能的正常运行。
为了不影响程序的流畅执行,提示音的播放显然不能占据业务线程的资源。我们需要额外启动一个线程来专门处理这个任务。
但是,大多数的提示音播放都是短暂且简单。如果每次播放提示音时都新建一个线程,且不说创建线程也需要大量时间,可能影响业务正常的执行任务的流程,就光是其频繁创建线程的开销也是不能接受的。
因此,更合理的方案是:在程序启动时,就启动一个专门用于播放提示音的线程。当没有需要播放的提示时,该线程会一直处于等待状态;一旦有提示音需要播放,线程就被唤醒,完成播放任务。
具体来说,我们可以通过条件变量来实现这一逻辑,核心是监控一个音频队列。我们可以封装一个类型,包含以下功能:
这种设计通过合理利用条件变量和互斥量,不仅有效减少了 CPU 的无效开销,还能够确保主线程的顺畅运行。它不仅适用于提示音的播放,还能扩展用于其他类似的后台任务场景。
我们引入 SFML 三方库进行声音播放,然后再自己进行上层封装。
class AudioPlayer {
+public:
+ AudioPlayer() : stop{ false }, player_thread{ &AudioPlayer::playMusic, this }
+ {}
+
+ ~AudioPlayer() {
+ // 等待队列中所有音乐播放完毕
+ while (!audio_queue.empty()) {
+ std::this_thread::sleep_for(50ms);
+ }
+ stop = true;
+ cond.notify_all();
+ if (player_thread.joinable()) {
+ player_thread.join();
+ }
+ }
+
+ void addAudioPath(const std::string& path) {
+ std::lock_guard<std::mutex> lock{ mtx }; // 互斥量确保了同一时间不会有其它地方在操作共享资源(队列)
+ audio_queue.push(path); // 为队列添加元素 表示有新的提示音需要播放
+ cond.notify_one(); // 通知线程新的音频
+ }
+
+private:
+ void playMusic() {
+ while (!stop) {
+ std::string path;
+ {
+ std::unique_lock<std::mutex> lock{ mtx };
+ cond.wait(lock, [this] { return !audio_queue.empty() || stop; });
+
+ if (audio_queue.empty()) return; // 防止在对象为空时析构出错
+
+ path = audio_queue.front(); // 从队列中取出元素
+ audio_queue.pop(); // 取出后就删除元素,表示此元素已被使用
+ }
+
+ if (!music.openFromFile(path)) {
+ std::cerr << "无法加载音频文件: " << path << std::endl;
+ continue; // 继续播放下一个音频
+ }
+
+ music.play();
+
+ // 等待音频播放完毕
+ while (music.getStatus() == sf::SoundSource::Playing) {
+ sf::sleep(sf::seconds(0.1f)); // sleep 避免忙等占用 CPU
+ }
+ }
+ }
+
+ std::atomic<bool> stop; // 控制线程的停止与退出,
+ std::thread player_thread; // 后台执行音频任务的专用线程
+ std::mutex mtx; // 保护共享资源
+ std::condition_variable cond; // 控制线程等待和唤醒,当有新任务时通知音频线程
+ std::queue<std::string> audio_queue; // 音频任务队列,存储待播放的音频文件路径
+ sf::Music music; // SFML 音频播放器,用于加载和播放音频文件
+};
该代码实现了一个简单的后台音频播放类型,通过条件变量和互斥量确保播放线程 playMusic
只在只在有音频任务需要播放时工作(当外部通过调用 addAudioPath()
向队列添加播放任务时)。在没有任务时,线程保持等待状态,避免占用 CPU 资源影响主程序的运行。
注意
其实这段代码还存在着一个初始化顺序导致的问题,见 #27
此外,关于提示音的播报,为了避免每次都手动添加路径,我们可以创建一个音频资源数组,便于使用:
此外,关于提示音的播报,为了避免每次都手动添加路径,我们可以创建一个音频资源数组,便于使用:
static constexpr std::array soundResources{
+ "./sound/01初始化失败.ogg",
+ "./sound/02初始化成功.ogg",
+ "./sound/03试剂不足,请添加.ogg",
+ "./sound/04试剂已失效,请更新.ogg",
+ "./sound/05清洗液不足,请添加.ogg",
+ "./sound/06废液桶即将装满,请及时清空.ogg",
+ "./sound/07废料箱即将装满,请及时清空.ogg",
+ "./sound/08激发液A液不足,请添加.ogg",
+ "./sound/09激发液B液不足,请添加.ogg",
+ "./sound/10反应杯不足,请添加.ogg",
+ "./sound/11检测全部完成.ogg"
+};
为了提高代码的可读性,我们还可以使用一个枚举类型来表示音频资源的索引:
enum SoundIndex {
+ InitializationFailed,
+ InitializationSuccessful,
+ ReagentInsufficient,
+ ReagentExpired,
+ CleaningAgentInsufficient,
+ WasteBinAlmostFull,
+ WasteContainerAlmostFull,
+ LiquidAInsufficient,
+ LiquidBInsufficient,
+ ReactionCupInsufficient,
+ DetectionCompleted,
+ SoundCount // 总音频数量,用于计数
+};
需要注意的是 SFML不支持 .mp3
格式的音频文件,大家可以使用 ffmpeg 或者其它软件网站将音频转换为支持的格式。
如果是测试使用,不知道去哪生成这些语音播报,我们推荐 tts-vue
。
我们的代码也可以在 Linux 中运行,并且整体仅需 C++11 标准,除了
soundResources
数组以外。
SFML 依赖于 FLAC 和 OpenAL 这两个库。在 Windows 上下载的 SFML 版本已包含这些依赖,但在 Linux 上需要用户自行下载并安装它们。如:sudo apt-get install libflac-dev +sudo apt-get install libopenal-dev
future
举个例子:我们在车站等车,你可能会做一些别的事情打发时间,比如学习现代 C++ 模板教程、观看 mq白 的视频教程、玩手机等。不过,你始终在等待一件事情:车到站。
C++ 标准库将这种事件称为 future。它用于处理线程中需要等待某个事件的情况,线程知道预期结果。等待的同时也可以执行其它的任务。
C++ 标准库有两种 future,都声明在 <future>
头文件中:独占的 std::future
、共享的 std::shared_future
。它们的区别与 std::unique_ptr
和 std::shared_ptr
类似。std::future
只能与单个指定事件关联,而 std::shared_future
能关联多个事件。它们都是模板,它们的模板类型参数,就是其关联的事件(函数)的返回类型。当多个线程需要访问一个独立 future 对象时, 必须使用互斥量或类似同步机制进行保护。而多个线程访问同一共享状态,若每个线程都是通过其自身的 shared_future
对象副本进行访问,则是安全的。
最简单有效的使用是,我们先前讲的 std::thread
在线程中执行任务是没有返回值的,这个问题就能使用 future 解决。
假设需要执行一个耗时任务并获取其返回值,但是并不急切的需要它。那么就可以启动新线程计算,然而 std::thread
没提供直接从线程获取返回值的机制。所以我们可以使用 std::async
函数模板。
使用 std::async
启动一个异步任务,它会返回一个 std::future
对象,这个对象和任务关联,将持有最终计算出来的结果。当需要任务执行完的结果的时候,只需要调用 get()
成员函数,就会阻塞直到 future
为就绪为止(即任务执行完毕),返回执行结果。valid()
成员函数检查 future 当前是否关联共享状态,即是否当前关联任务。还未关联,或者任务已经执行完(调用了 get()、set()),都会返回 false
。
#include <iostream>
+#include <thread>
+#include <future> // 引入 future 头文件
+
+int task(int n) {
+ std::cout << "异步任务 ID: " << std::this_thread::get_id() << '\\n';
+ return n * n;
+}
+
+int main() {
+ std::future<int> future = std::async(task, 10);
+ std::cout << "main: " << std::this_thread::get_id() << '\\n';
+ std::cout << std::boolalpha << future.valid() << '\\n'; // true
+ std::cout << future.get() << '\\n';
+ std::cout << std::boolalpha << future.valid() << '\\n'; // false
+}
运行测试。
与 std::thread
一样,std::async
支持任意可调用(Callable)对象,以及传递调用参数。包括支持使用 std::ref
,以及支持只能移动的类型。我们下面详细聊一下 std::async
参数传递的事。
struct X{
+ int operator()(int n)const{
+ return n * n;
+ }
+};
+struct Y{
+ int f(int n)const{
+ return n * n;
+ }
+};
+void f(int& p) { std::cout << &p << '\\n'; }
+
+int main(){
+ Y y;
+ int n = 0;
+ auto t1 = std::async(X{}, 10);
+ auto t2 = std::async(&Y::f,&y,10);
+ auto t3 = std::async([] {});
+ auto t4 = std::async(f, std::ref(n));
+ std::cout << &n << '\\n';
+}
运行测试。
如你所见,它支持所有可调用(Callable)对象,并且也是默认按值复制,必须使用 std::ref
才能传递引用。并且它和 std::thread
一样,内部会将保有的参数副本转换为右值表达式进行传递,这是为了那些只支持移动的类型,左值引用没办法引用右值表达式,所以如果不使用 std::ref
,这里 void f(int&)
就会导致编译错误,如果是 void f(const int&)
则可以通过编译,不过引用的不是我们传递的局部对象。
void f(const int& p) {}
+void f2(int& p ){}
+
+int n = 0;
+std::async(f, n); // OK! 可以通过编译,不过引用的并非是局部的n
+std::async(f2, n); // Error! 无法通过编译
我们来展示使用 std::move
,也就是移动传递参数并接受返回值:
struct move_only{
+ move_only() { std::puts("默认构造"); }
+ move_only(move_only&&)noexcept { std::puts("移动构造"); }
+ move_only& operator=(move_only&&) noexcept {
+ std::puts("移动赋值");
+ return *this;
+ }
+ move_only(const move_only&) = delete;
+};
+
+move_only task(move_only x){
+ std::cout << "异步任务 ID: " << std::this_thread::get_id() << '\\n';
+ return x;
+}
+
+int main(){
+ move_only x;
+ std::future<move_only> future = std::async(task, std::move(x));
+ std::this_thread::sleep_for(std::chrono::seconds(1));
+ std::cout << "main\\n";
+ move_only result = future.get(); // 等待异步任务执行完毕
+}
运行测试。
如你所见,它支持只移动类型,我们将参数使用 std::move
传递,接收参数的时候直接调用 get
函数即可。
接下来我们聊 std::async
的执行策略,我们前面一直没有使用,其实就是在传递可调用对象与参数之前传递枚举值罢了:
std::launch::async
在不同线程上执行异步任务。std::launch::deferred
惰性求值,不创建线程,等待 future
对象调用 wait
或 get
成员函数的时候执行任务。而我们先前一直没有写明这个参数,是因为 std::async
函数模板有两个重载,不给出执行策略就是以:std::launch::async | std::launch::deferred
调用另一个重载版本(这一点中在源码中很明显),此策略表示由实现选择到底是否创建线程执行异步任务。典型情况是,如果系统资源充足,并且异步任务的执行不会导致性能问题,那么系统可能会选择在新线程中执行任务。但是,如果系统资源有限,或者延迟执行可以提高性能或节省资源,那么系统可能会选择延迟执行。
如果你阅读
libstdc++
的代码,会发现的确如此。然而值得注意的是,在 MSVC STL 的实现中,
launch::async | launch::deferred
与launch::async
执行策略毫无区别,源码如下:template <class _Ret, class _Fty> +_Associated_state<typename _P_arg_type<_Ret>::type>* _Get_associated_state(launch _Psync, _Fty&& _Fnarg) { + // construct associated asynchronous state object for the launch type + switch (_Psync) { // select launch type + case launch::deferred: + return new _Deferred_async_state<_Ret>(_STD forward<_Fty>(_Fnarg)); + case launch::async: // TRANSITION, fixed in vMajorNext, should create a new thread here + default: + return new _Task_async_state<_Ret>(_STD forward<_Fty>(_Fnarg)); + } +}
且
_Task_async_state
会通过::Concurrency::create_task
[1] 从线程池中获取线程并执行任务返回包装对象。简而言之,使用
std::async
,只要不是launch::deferred
策略,那么 MSVC STL 实现中都是必然在线程中执行任务。因为是线程池,所以执行新任务是否创建新线程,任务执行完毕线程是否立即销毁,不确定。
我们来展示一下:
void f(){
+ std::cout << std::this_thread::get_id() << '\\n';
+}
+
+int main(){
+ std::cout << std::this_thread::get_id() << '\\n';
+ auto f1 = std::async(std::launch::deferred, f);
+ f1.wait(); // 在 wait() 或 get() 调用时执行,不创建线程
+ auto f2 = std::async(std::launch::async,f); // 创建线程执行异步任务
+ auto f3 = std::async(std::launch::deferred | std::launch::async, f); // 实现选择的执行方式
+}
运行测试。
其实到此基本就差不多了,我们再介绍两个常见问题即可:
如果从 std::async
获得的 std::future
没有被移动或绑定到引用,那么在完整表达式结尾, std::future
的**析构函数将阻塞,直到到异步任务完成**。因为临时对象的生存期就在这一行,而对象生存期结束就会调用调用析构函数。
std::async(std::launch::async, []{ f(); }); // 临时量的析构函数等待 f()
+std::async(std::launch::async, []{ g(); }); // f() 完成前不开始
如你所见,这并不能创建异步任务,它会阻塞,然后逐个执行。
被移动的 std::future
没有所有权,失去共享状态,不能调用 get
、wait
成员函数。
auto t = std::async([] {});
+std::future<void> future{ std::move(t) };
+t.wait(); // Error! 抛出异常
如同没有线程资源所有权的 std::thread
对象调用 join()
一样错误,这是移动语义的基本语义逻辑。
future
与 std::packaged_task
类模板 std::packaged_task
包装任何可调用(Callable)目标(函数、lambda 表达式、bind 表达式或其它函数对象),使得能异步调用它。其返回值或所抛异常被存储于能通过 std::future 对象访问的共享状态中。
通常它会和 std::future
一起使用,不过也可以单独使用,我们一步一步来:
std::packaged_task<double(int, int)> task([](int a, int b){
+ return std::pow(a, b);
+});
+task(10, 2); // 执行传递的 lambda,但无法获取返回值
它有 operator()
的重载,它会执行我们传递的可调用(Callable)对象,不过这个重载的返回类型是 void
没办法获取返回值。
如果想要异步的获取返回值,我们需要在调用 operator()
之前,让它和 future 关联,然后使用 future.get()
,也就是:
std::packaged_task<double(int, int)> task([](int a, int b){
+ return std::pow(a, b);
+});
+std::future<double>future = task.get_future();
+task(10, 2); // 此处执行任务
+std::cout << future.get() << '\\n'; // 不阻塞,此处获取返回值
运行测试。
先关联任务,再执行任务,当我们想要获取任务的返回值的时候,就 future.get()
即可。值得注意的是,任务并不会在线程中执行,想要在线程中执行异步任务,然后再获取返回值,我们可以这么做:
std::packaged_task<double(int, int)> task([](int a, int b){
+ return std::pow(a, b);
+});
+std::future<double> future = task.get_future();
+std::thread t{ std::move(task),10,2 }; // 任务在线程中执行
+// todo.. 幻想还有许多耗时的代码
+t.join();
+
+std::cout << future.get() << '\\n'; // 并不阻塞,获取任务返回值罢了
运行测试。
因为 task
本身是重载了 operator()
的,是可调用对象,自然可以传递给 std::thread
执行,以及传递调用参数。唯一需要注意的是我们使用了 std::move
,这是因为 std::packaged_task
只能移动,不能复制。
简而言之,其实 std::packaged_task
也就是一个“包装”类而已,它本身并没什么特殊的,老老实实执行我们传递的任务,且方便我们获取返回值罢了,明确这一点,那么一切都不成问题。
std::packaged_task
也可以在线程中传递,在需要的时候获取返回值,而非像上面那样将它自己作为可调用对象:
template<typename R, typename...Ts, typename...Args>
+ requires std::invocable<std::packaged_task<R(Ts...)>&, Args...>
+void async_task(std::packaged_task<R(Ts...)>& task, Args&&...args) {
+ // todo..
+ task(std::forward<Args>(args)...);
+}
+
+int main() {
+ std::packaged_task<int(int,int)> task([](int a,int b){
+ return a + b;
+ });
+
+ int value = 50;
+ std::future<int> future = task.get_future();
+ // 创建一个线程来执行异步任务
+ std::thread t{ [&] {async_task(task, value, value); } };
+ std::cout << future.get() << '\\n';
+ t.join();
+}
运行测试。
我们套了一个 lambda,这是因为函数模板不是函数,它并非具体类型,没办法直接被那样传递使用,只能包一层了。这只是一个简单的示例,展示可以使用 std::packaged_task
作函数形参,然后我们来传递任务进行异步调用等操作。
我们再将第二章实现的并行 sum
改成 std::package_task
+ std::future
的形式:
template<typename ForwardIt>
+auto sum(ForwardIt first, ForwardIt last) {
+ using value_type = std::iter_value_t<ForwardIt>;
+ std::size_t num_threads = std::thread::hardware_concurrency();
+ std::ptrdiff_t distance = std::distance(first, last);
+
+ if (distance > 1024000) {
+ // 计算每个线程处理的元素数量
+ std::size_t chunk_size = distance / num_threads;
+ std::size_t remainder = distance % num_threads;
+
+ // 存储每个线程要执行的任务
+ std::vector<std::packaged_task<value_type()>>tasks;
+ // 和每一个任务进行关联的 future 用于获取返回值
+ std::vector<std::future<value_type>>futures(num_threads);
+
+ // 存储关联线程的线程对象
+ std::vector<std::thread> threads;
+
+ // 制作任务、与 future 关联、启动线程执行
+ auto start = first;
+ for (std::size_t i = 0; i < num_threads; ++i) {
+ auto end = std::next(start, chunk_size + (i < remainder ? 1 : 0));
+ tasks.emplace_back(std::packaged_task<value_type()>{[start, end, i] {
+ return std::accumulate(start, end, value_type{});
+ }});
+ start = end; // 开始迭代器不断向前
+ futures[i] = tasks[i].get_future(); // 任务与 std::future 关联
+ threads.emplace_back(std::move(tasks[i]));
+ }
+
+ // 等待所有线程执行完毕
+ for (auto& thread : threads)
+ thread.join();
+
+ // 汇总线程的计算结果
+ value_type total_sum {};
+ for (std::size_t i = 0; i < num_threads; ++i) {
+ total_sum += futures[i].get();
+ }
+ return total_sum;
+ }
+
+ value_type total_sum = std::accumulate(first, last, value_type{});
+ return total_sum;
+}
运行测试。
相比于之前,其实不同无非是定义了 std::vector<std::packaged_task<value_type()>> tasks
与 std::vector<std::future<value_type>> futures
,然后在循环中制造任务插入容器,关联 future,再放到线程中执行。最后汇总的时候写一个循环,futures[i].get()
获取任务的返回值加起来即可。
到此,也就可以了。
std::promise
类模板 std::promise
用于存储一个值或一个异常,之后通过 std::promise
对象所创建的 std::future 对象异步获得。
// 计算函数,接受一个整数并返回它的平方
+void calculate_square(std::promise<int> promiseObj, int num) {
+ // 模拟一些计算
+ std::this_thread::sleep_for(std::chrono::seconds(1));
+
+ // 计算平方并设置值到 promise 中
+ promiseObj.set_value(num * num);
+}
+
+// 创建一个 promise 对象,用于存储计算结果
+std::promise<int> promise;
+
+// 从 promise 获取 future 对象进行关联
+std::future<int> future = promise.get_future();
+
+// 启动一个线程进行计算
+int num = 5;
+std::thread t(calculate_square, std::move(promise), num);
+
+// 阻塞,直到结果可用
+int result = future.get();
+std::cout << num << " 的平方是:" << result << std::endl;
+
+t.join();
运行测试。
我们在新线程中通过调用 set_value()
函数设置 promise
的值,并在主线程中通过与其关联的 future 对象的 get()
成员函数获取这个值,如果promise
的值还没有被设置,那么将阻塞当前线程,直到被设置为止。同样的 std::promise
只能移动,不可复制,所以我们使用了 std::move
进行传递。
除了 set_value()
函数外,std::promise
还有一个 set_exception()
成员函数,它接受一个 std::exception_ptr
类型的参数,这个参数通常通过 std::current_exception()
获取,用于指示当前线程中抛出的异常。然后,std::future
对象通过 get()
函数获取这个异常,如果 promise
所在的函数有异常被抛出,则 std::future
对象会重新抛出这个异常,从而允许主线程捕获并处理它。
void throw_function(std::promise<int> prom) {
+ try {
+ throw std::runtime_error("一个异常");
+ }
+ catch (...) {
+ prom.set_exception(std::current_exception());
+ }
+}
+
+int main() {
+ std::promise<int> prom;
+ std::future<int> fut = prom.get_future();
+
+ std::thread t(throw_function, std::move(prom));
+
+ try {
+ std::cout << "等待线程执行,抛出异常并设置\\n";
+ fut.get();
+ }
+ catch (std::exception& e) {
+ std::cerr << "来自线程的异常: " << e.what() << '\\n';
+ }
+ t.join();
+}
运行结果:
等待线程执行,抛出异常并设置
+来自线程的异常: 一个异常
你可能对这段代码还有一些疑问:我们写的是 promise<int>
,但是却没有使用 set_value
设置值,你可能会想着再写一行 prom.set_value(0)
?
共享状态的 promise 已经存储值或者异常,再次调用 set_value
(set_exception
) 会抛出 std::future_error 异常,将错误码设置为 promise_already_satisfied
。这是因为 std::promise
对象只能是存储值或者异常其中一种,而无法共存。
简而言之,set_value
与 set_exception
二选一,如果先前调用了 set_value
,就不可再次调用 set_exception
,反之亦然(不然就会抛出异常),示例如下:
void throw_function(std::promise<int> prom) {
+ prom.set_value(100);
+ try {
+ throw std::runtime_error("一个异常");
+ }
+ catch (...) {
+ try{
+ // 共享状态的 promise 已存储值,调用 set_exception 产生异常
+ prom.set_exception(std::current_exception());
+ }catch (std::exception& e){
+ std::cerr << "来自 set_exception 的异常: " << e.what() << '\\n';
+ }
+ }
+}
+
+int main() {
+ std::promise<int> prom;
+ std::future<int> fut = prom.get_future();
+
+ std::thread t(throw_function, std::move(prom));
+
+ std::cout << "等待线程执行,抛出异常并设置\\n";
+ std::cout << "值:" << fut.get() << '\\n'; // 100
+
+ t.join();
+}
运行结果:
等待线程执行,抛出异常并设置
+值:100
+来自 set_exception 的异常: promise already satisfied
需要注意的是,future 是一次性的,所以你需要注意移动。并且,调用 get
函数后,future 对象也会失去共享状态。
future
不再拥有共享状态(如之前所提到)。get
和 wait
函数要求 future
对象拥有共享状态,否则会抛出异常。get
成员函数时,future
对象必须拥有共享状态,但调用完成后,它就会失去共享状态,不能再次调用 get
。这是我们在本节需要特别讨论的内容。std::future<void>future = std::async([] {});
+std::cout << std::boolalpha << future.valid() << '\\n'; // true
+future.get();
+std::cout << std::boolalpha << future.valid() << '\\n'; // false
+try {
+ future.get(); // 抛出 future_errc::no_state 异常
+}
+catch (std::exception& e) {
+ std::cerr << e.what() << '\\n';
+}
运行测试。
这个问题在许多文档中没有明确说明,但通过阅读源码(MSVC STL),可以很清楚地理解:
// std::future<void>
+void get() {
+ // block until ready then return or throw the stored exception
+ future _Local{_STD move(*this)};
+ _Local._Get_value();
+}
+// std::future<T>
+_Ty get() {
+ // block until ready then return the stored result or throw the stored exception
+ future _Local{_STD move(*this)};
+ return _STD move(_Local._Get_value());
+}
+// std::future<T&>
+_Ty& get() {
+ // block until ready then return the stored result or throw the stored exception
+ future _Local{_STD move(*this)};
+ return *_Local._Get_value();
+}
如上所示,我们展示了 std::future
的所有特化中 get
成员函数的实现。注意到了吗?尽管我们可能不了解移动构造函数的具体实现,但根据通用的语义,可以看出 future _Local{_STD move(*this)};
将当前对象的共享状态转移给了这个局部对象,而局部对象在函数结束时析构。这意味着当前对象失去共享状态,并且状态被完全销毁。
另外一提,std::future<T>
这个特化,它 return std::move
是为了支持只能移动的类型能够使用 get
返回值,参见前文的 move_only
类型。
如果需要进行多次 get
调用,可以考虑使用下文提到的 std::shared_future
。
std::shared_future
之前的例子中我们一直使用 std::future
,但 std::future
有一个局限:future 是一次性的,它的结果只能被一个线程获取。get()
成员函数只能调用一次,当结果被某个线程获取后,std::future
就无法再用于其他线程。
int task(){
+ // todo..
+ return 10;
+}
+
+void thread_functio(std::future<int>& fut){
+ // todo..
+ int result = fut.get();
+ std::cout << result << '\\n';
+ // todo..
+}
+
+int main(){
+ auto future = std::async(task); // 启动耗时的异步任务
+
+ // 可能有多个线程都需要此任务的返回值,于是我们将与其关联的 future 对象的引入传入
+ std::thread t{ thread_functio,std::ref(future) };
+ std::thread t2{ thread_functio,std::ref(future) };
+ t.join();
+ t2.join();
+}
可能有多个线程都需要耗时的异步任务的返回值,于是我们将与其关联的 future 对象的引入传给线程对象,让它能在需要的时候获取。
但是这存在个问题,future 是一次性的,只能被调用一次
get()
成员函数,所以以上代码存在问题。
此时就需要使用 std::shared_future
来替代 std::future
了。std::future
与 std::shared_future
的区别就如同 std::unique_ptr
、std::shared_ptr
一样。
std::future
是只能移动的,其所有权可以在不同的对象中互相传递,但只有一个对象可以获得特定的同步结果。而 std::shared_future
是可复制的,多个对象可以指代同一个共享状态。
在多个线程中对同一个 std::shared_future
对象进行操作时(如果没有进行同步保护)存在条件竞争。而从多个线程访问同一共享状态,若每个线程都是通过其自身的 shared_future
对象副本进行访问,则是安全的。
std::string fetch_data() {
+ std::this_thread::sleep_for(std::chrono::seconds(1)); // 模拟耗时操作
+ return "从网络获取的数据!";
+}
+
+int main() {
+ std::future<std::string> future_data = std::async(std::launch::async, fetch_data);
+
+ // // 转移共享状态,原来的 future 被清空 valid() == false
+ std::shared_future<std::string> shared_future_data = future_data.share();
+
+ // 第一个线程等待结果并访问数据
+ std::thread thread1([&shared_future_data] {
+ std::cout << "线程1:等待数据中..." << std::endl;
+ shared_future_data.wait();
+ std::cout << "线程1:收到数据:" << shared_future_data.get() << std::endl;
+ });
+
+ // 第二个线程等待结果并访问数据
+ std::thread thread2([&shared_future_data] {
+ std::cout << "线程2:等待数据中..." << std::endl;
+ shared_future_data.wait();
+ std::cout << "线程2:收到数据:" << shared_future_data.get() << std::endl;
+ });
+
+ thread1.join();
+ thread2.join();
+}
这段代码存在数据竞争,就如同我们先前所说:“在多个线程中对同一个 std::shared_future
对象进行操作时(如果没有进行同步保护)存在条件竞争”,它并没有提供线程安全的方式。而我们的 lambda 是按引用传递,也就是“同一个”进行操作了。可以改为:
std::string fetch_data() {
+ std::this_thread::sleep_for(std::chrono::seconds(1)); // 模拟耗时操作
+ return "从网络获取的数据!";
+}
+
+int main() {
+ std::future<std::string> future_data = std::async(std::launch::async, fetch_data);
+
+ std::shared_future<std::string> shared_future_data = future_data.share();
+
+ std::thread thread1([shared_future_data] {
+ std::cout << "线程1:等待数据中..." << std::endl;
+ shared_future_data.wait();
+ std::cout << "线程1:收到数据:" << shared_future_data.get() << std::endl;
+ });
+
+ std::thread thread2([shared_future_data] {
+ std::cout << "线程2:等待数据中..." << std::endl;
+ shared_future_data.wait();
+ std::cout << "线程2:收到数据:" << shared_future_data.get() << std::endl;
+ });
+
+ thread1.join();
+ thread2.join();
+}
这样访问的就都是 std::shared_future
的副本了,我们的 lambda 按复制捕获 std::shared_future
对象,每个线程都有一个 shared_future 的副本,这样不会有任何问题。这一点和 std::shared_ptr
类似[2]。
std::promise
也同,它的 get_future()
成员函数一样可以用来构造 std::shared_future
,虽然它的返回类型是 std::future
,不过不影响,这是因为 std::shared_future
有一个 std::future<T>&&
参数的构造函数,转移 std::future
的所有权。
std::promise<std::string> p;
+std::shared_future<std::string> sf{ p.get_future() }; // 隐式转移所有权
就不需要再强调了。
阻塞调用会将线程挂起一段(不确定的)时间,直到对应的事件发生。通常情况下,这样的方式很好,但是在一些情况下,需要限定线程等待的时间,因为无限期地等待事件发生可能会导致性能下降或资源浪费。一个常见的例子是在很多网络库中的 connect
函数,这个函数调用是阻塞的,但是也是限时的,一定时间内没有连接到服务器就不会继续阻塞了,会进行其它处理,比如抛出异常。
介绍两种指定超时的方式,一种是“时间段”,另一种是“时间点”,其实就是先前讲的 std::this::thread::sleep_for
与 std::this_thread::sleep_until
的区别。前者是需要指定等待一段时间(比如 10 毫秒)。而后者是指定等待到一个具体的时间点(比如到 2024-05-07T12:01:10.123)。多数函数都对两种超时方式进行处理。处理持续时间的函数以 _for
作为后缀,处理绝对时间的函数以 _until
作为后缀。
条件变量 std::condition_variable
的等待函数,也有两个超时的版本 wait_for
和 wait_until
。它们和我们先前讲的 wait
成员函数一样有两个重载,可以选择是否传递一个谓词。它们相比于 wait
多了一个解除阻塞的可能,即:超过指定的时长或抵达指定的时间点。
在讲述它的使用细节之前,我们还是要来先聊一下 C++ 中的时间库(chrono),指定时间的方式,它较为麻烦。我们分:时钟(clock)、时间段(duration)、*时间点(time point)*三个阶段稍微介绍一下。
在 C++ 标准库中,时钟被视为时间信息的来源。C++ 定义了很多种时间类型,每种时钟类型都提供了四种不同的信息:
当前时间可以通过静态成员函数 now
获取,例如,std::chrono::system_clock::now()
会返回系统的当前时间。特定的时间点则可以通过 time_point
来指定。system_clock::now()
的返回类型就是 time_point
。
时钟节拍被指定为 1/x(x 在不同硬件上有不同的值)秒,这是由时间周期所决定。假设一个时钟一秒有 25 个节拍,因此一个周期为 std::ratio<1,25>
。当一个时钟的时钟节拍每 2.5 秒一次,周期就可以表示为 std::ratio<5,2>
。
类模板 std::chrono::duration
表示时间间隔。
template<class Rep, class Period = std::ratio<1>>
+class duration;
std::ratio
是一个分数类模板,它有两个非类型模板参数,也就是分子与分母,分母有默认实参 1,所以std::ratio<1>
等价于std::ratio<1,1>
。
如你所见,它默认的时钟节拍是 1,这是一个很重要的类,标准库通过它定义了很多的时间类型,比如 std::chrono::minutes
是分钟类型,那么它的 Period
就是 std::ratio<60>
,因为一分钟等于 60 秒。
using minutes = duration<int, ratio<60>>;
稳定时钟(Steady Clock)是指提供稳定、持续递增的时间流逝信息的时钟。它的特点是不受系统时间调整或变化的影响,即使在系统休眠或时钟调整的情况下,它也能保持稳定。在 C++ 标准库中,std::chrono::steady_clock
就是一个稳定时钟。它通常用于测量时间间隔和性能计时等需要高精度和稳定性的场景。可以通过 is_steady
静态常量判断当前时钟是否是稳定时钟。
稳定时钟的主要优点在于,它可以提供相对于起始时间的稳定的递增时间,因此适用于需要保持时间顺序和不受系统时间变化影响的应用场景。相比之下,像 std::chrono::system_clock
这样的系统时钟可能会受到系统时间调整或变化的影响,因此在某些情况下可能不适合对时间间隔进行精确测量。
不管使用哪种时钟获取时间,C++ 都提供了函数,可以将时间点转换为 time_t 类型的值:
auto now = std::chrono::system_clock::now();
+time_t now_time = std::chrono::system_clock::to_time_t(now);
+std::cout << "Current time:\\t" << std::put_time(std::localtime(&now_time), "%H:%M:%S\\n");
+
+auto now2 = std::chrono::steady_clock::now();
+now_time = std::chrono::system_clock::to_time_t(now);
+std::cout << "Current time:\\t" << std::put_time(std::localtime(&now_time), "%H:%M:%S\\n");
C++ 的时间库极其繁杂,主要在于类型之多,以及实现之复杂。根据我们的描述,了解基本构成、概念、使用,即可。
时间部分最简单的就是时间段,主要的内容就是我们上面讲的类模板 std::chrono::duration
,它用于对时间段进行处理。
它的第一个参数是类型表示,第二个参数就是先前提到的“节拍”,需要传递一个 std::ratio
类型,也就是一个时钟所用的秒数。
标准库在 std::chrono
命名空间内为时间段提供了一系列的类型,它们都是通过 std::chrono::duration
定义的别名:
using nanoseconds = duration<long long, nano>;
+using microseconds = duration<long long, micro>;
+using milliseconds = duration<long long, milli>;
+using seconds = duration<long long>;
+using minutes = duration<int, ratio<60>>;
+using hours = duration<int, ratio<3600>>;
+// CXX20
+using days = duration<int, ratio_multiply<ratio<24>, hours::period>>;
+using weeks = duration<int, ratio_multiply<ratio<7>, days::period>>;
+using years = duration<int, ratio_multiply<ratio<146097, 400>, days::period>>;
+using months = duration<int, ratio_divide<years::period, ratio<12>>>;
如果没有指明 duration
的第二个非类型模板参数,那么代表默认 std::ratio<1>
,比如 seconds
也就是一秒。
如上,是 MSVC STL 定义的,看似有一些没有使用 ratio
作为第二个参数,其实也还是别名罢了,见:
using milli = ratio<1, 1000>; // 千分之一秒,也就是一毫秒了
并且为了方便使用,在 C++14 标准库增加了时间字面量,存在于 std::chrono_literals
命名空间中,让我们得以简单的使用:
using namespace std::chrono_literals;
+
+auto one_nanosecond = 1ns;
+auto one_microsecond = 1us;
+auto one_millisecond = 1ms;
+auto one_second = 1s;
+auto one_minute = 1min;
+auto one_hour = 1h;
当不要求截断值的情况下(时转换为秒时没问题的,但反过来不行)时间段有隐式转换,显式转换可以由 std::chrono::duration_cast<>
来完成。
std::chrono::milliseconds ms{ 3999 };
+std::chrono::seconds s = std::chrono::duration_cast<std::chrono::seconds>(ms);
+std::cout << s.count() << '\\n';
这里的结果是截断的,而不会进行所谓的四舍五入,3999
毫秒,也就是 3.999
秒最终的值是 3
。
很多时候这并不是我们想要的,比如我们想要的其实是输出
3.999
秒,而不是3
秒 或者3999
毫秒。seconds 是
duration<long long>
这意味着它无法接受浮点数,我们直接改成duration<double>
即可:std::chrono::duration<double> s = std::chrono::duration_cast<std::chrono::duration<double>>(ms);
当然了,这样写很冗余,并且这种形式的转换是可以直接隐式的,也就是其实我们可以直接:
std::chrono::duration<double> s = ms;
无需使用
duration_cast
,可以直接隐式转换。另外我们用的
duration
都是省略了ratio
的,其实默认类型就是ratio<1>
,代表一秒。参见源码声明:_EXPORT_STD template <class _Rep, class _Period = ratio<1>> +class duration;
时间库支持四则运算,可以对两个时间段进行加减乘除。时间段对象可以通过 count()
成员函数获得计次数。例如 std::chrono::milliseconds{123}.count()
的结果就是 123。
基于时间段的等待都是由 std::chrono::duration<>
来完成。例如:等待一个 future 对象在 35 毫秒内变为就绪状态:
std::future<int> future = std::async([] {return 6; });
+if (future.wait_for(35ms) == std::future_status::ready)
+ std::cout << future.get() << '\\n';
wait_for
: 等待结果,如果在指定的超时间隔后仍然无法得到结果,则返回。它的返回类型是一个枚举类 std::future_status
,三个枚举项分别表示三种 future 状态。
deferred | 共享状态持有的函数正在延迟运行,结果将仅在明确请求时计算 |
---|---|
ready | 共享状态就绪 |
timeout | 共享状态在经过指定的等待时间内仍未就绪 |
timeout
超时,也很好理解,那我们就提一下 deferred
:
auto future = std::async(std::launch::deferred, []{});
+if (future.wait_for(35ms) == std::future_status::deferred)
+ std::cout << "future_status::deferred " << "正在延迟执行\\n";
+future.wait(); // 在 wait() 或 get() 调用时执行,不创建线程
时间点可用 std::chrono::time_point<>
来表示,第一个模板参数用来指定使用的时钟,第二个模板参数用来表示时间单位(std::chrono::duration<>
)。时间点顾名思义就是时间中的一个点,在 C++ 中用于表达当前时间,先前提到的静态成员函数 now()
获取当前时间,它们的返回类型都是 std::chrono::time_point
。
template<
+ class Clock,
+ class Duration = typename Clock::duration
+> class time_point;
如你所见,它的第二个模板参数是时间段,就是时间的间隔,其实也就可以理解为表示时间点的精度,默认是根据第一个参数时钟得到的,所以假设有类型:
std::chrono::time_point<std::chrono::system_clock>
那它等价于:
std::chrono::time_point<std::chrono::system_clock, std::chrono::system_clock::duration>
也就是说第二个参数的实际类型是:
std::chrono::duration<long long,std::ratio<1, 10000000>> // // 100 nanoseconds
也就是说 std::chrono::time_point<std::chrono::system_clock>
的精度是 100 纳秒。
更多的问题参见源码都很直观。
注意,这里的精度并非是实际的时间精度。时间和硬件系统等关系极大,以 windows 为例:
Windows 内核中的时间间隔计时器默认每隔 15.6 毫秒触发一次中断。因此,如果你使用基于系统时钟的计时方法,默认情况下精度约为 15.6 毫秒。不可能达到纳秒级别。
由于这个系统时钟的限制,那些基于系统时钟的 API(例如
Sleep()
、WaitForSingleObject()
等)的最小睡眠时间默认就是 15.6 毫秒左右。如:
std::this_thread::sleep_for(std::chrono::milliseconds(1));
不过我们也可以使用系统 API 调整系统时钟的精度,需要链接 windows 多媒体库
winmm.lib
,然后使用 API:timeBeginPeriod(1); // 设置时钟精度为 1 毫秒 +// todo.. +timeEndPeriod(1); // 恢复默认精度
同样的,时间点也支持加减以及比较操作。
std::chrono::steady_clock::now() + std::chrono::nanoseconds(500); // 500 纳秒之后的时间
可以减去一个时间点,结果是两个时间点的时间差。这对于代码块的计时是很有用的,如:
auto start = std::chrono::steady_clock::now();
+std::this_thread::sleep_for(std::chrono::seconds(1));
+auto end = std::chrono::steady_clock::now();
+
+auto result = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
+std::cout << result.count() << '\\n';
运行测试。
我们进行了一个显式的转换,最终输出的是以毫秒作为单位,有可能不会是 1000,没有这么精确。
等待条件变量满足条件——带超时功能
using namespace std::chrono_literals;
+
+std::condition_variable cv;
+bool done{};
+std::mutex m;
+
+bool wait_loop() {
+ const auto timeout = std::chrono::steady_clock::now() + 500ms;
+ std::unique_lock<std::mutex> lk{ m };
+ while (!done) {
+ if (cv.wait_until(lk, timeout) == std::cv_status::timeout) {
+ std::cout << "超时 500ms\\n";
+ return false;
+ }
+ }
+ return true;
+}
运行测试。
_until
也就是等待到一个时间点,我们设置的是等待到当前时间往后 500 毫秒。如果超过了这个时间还没有被唤醒,那就打印超时,并退出循环,函数返回 false
。
到此,时间点的知识也就足够了。
在开发带有 UI 的程序时,主线程用于处理 UI 更新和用户交互,如果在主线程中执行耗时任务会导致界面卡顿。因此,需要使用异步任务来减轻主线程的压力。以下是一个使用 Qt 实现异步任务的示例,展示了如何在不阻塞 UI 线程的情况下执行耗时任务,并更新进度条。
在 Qt 中,GUI 控件通常只能在创建它们的线程中进行操作,因为它们是线程不安全的。我们可以使用 QMetaObject::invokeMethod
来跨线程调用主线程上的控件方法,从而在其他线程中安全地更新 UI 控件。以下代码示例展示了如何通过 QMetaObject::invokeMethod
确保 UI 控件的更新操作在主线程中执行。
void task(){
+ future = std::async(std::launch::async, [=] {
+ QMetaObject::invokeMethod(this, [this] {
+ button->setEnabled(false);
+ progressBar->setRange(0, 1000);
+ button->setText("正在执行...");
+ });
+ for (int i = 0; i < 1000; ++i) {
+ std::this_thread::sleep_for(10ms);
+ QMetaObject::invokeMethod(this, [this, i] {
+ progressBar->setValue(i);
+ });
+ }
+ QMetaObject::invokeMethod(this, [this] {
+ button->setText("start");
+ button->setEnabled(true);
+ });
+ });
+}
上面的代码创建了一个异步任务,并指明了执行策略。任务在线程中执行,不会阻塞 UI 线程。如果不这样做,界面将会卡顿(可以尝试将函数的第一行与最后一行注释掉以验证这一点)。
在启动进度条后,能够正常点击“测试”按钮并触发弹窗,说明 UI 没有被阻塞。相反,如果不使用线程,界面将会卡住,无法点击“测试”按钮或移动窗口。
项目使用 Visual Studio + CMake,可以直接安装 Qt 插件后打开此项目。项目结构简单,所有界面与设置均通过代码控制,无需进行其他 UI 操作。只需关注 async_progress_bar.h
、async_progress_bar.cpp
和 main.cpp
这三个文件,它们位于仓库的 code
文件夹中。
class async_progress_bar : public QMainWindow{
+ Q_OBJECT
+
+public:
+ async_progress_bar(QWidget *parent = nullptr);
+ ~async_progress_bar();
+
+ void task(){
+ future = std::async(std::launch::async, [=] {
+ QMetaObject::invokeMethod(this, [this] {
+ // 这里显示的线程 ID 就是主线程,代表这些任务就是在主线程,即 UI 线程执行
+ QMessageBox::information(nullptr, "线程ID", std::to_string(_Thrd_id()).c_str());
+ button->setEnabled(false);
+ progress_bar->setRange(0, 1000);
+ button->setText("正在执行...");
+ });
+ for (int i = 0; i <= 1000; ++i) {
+ std::this_thread::sleep_for(10ms);
+ QMetaObject::invokeMethod(this, [this, i] {
+ progress_bar->setValue(i);
+ });
+ }
+ QMetaObject::invokeMethod(this, [this] {
+ button->setText("start");
+ button->setEnabled(true);
+ });
+ // 不在 invokeMethod 中获取线程 ID,这里显示的是子线程的ID
+ auto s = std::to_string(_Thrd_id());
+ QMetaObject::invokeMethod(this, [=] {
+ QMessageBox::information(nullptr, "线程ID", s.c_str());
+ });
+ });
+ }
+private:
+ QString progress_bar_style =
+ "QProgressBar {"
+ " border: 2px solid grey;"
+ " border-radius: 5px;"
+ " background-color: lightgrey;"
+ " text-align: center;" // 文本居中
+ " color: #000000;" // 文本颜色
+ "}"
+ "QProgressBar::chunk {"
+ " background-color: #7FFF00;"
+ " width: 10px;" // 设置每个进度块的宽度
+ " font: bold 14px;" // 设置进度条文本字体
+ "}";
+ QString button_style =
+ "QPushButton {"
+ " text-align: center;" // 文本居中
+ "}";
+ QProgressBar* progress_bar{};
+ QPushButton* button{};
+ QPushButton* button2{};
+ Ui::async_progress_barClass ui{};
+ std::future<void>future;
+};
+// 创建控件 设置布局、样式 连接信号
+async_progress_bar::async_progress_bar(QWidget *parent)
+ : QMainWindow{ parent }, progress_bar{ new QProgressBar(this) },
+ button{ new QPushButton("start",this) },button2{ new QPushButton("测试",this) } {
+ ui.setupUi(this);
+
+ progress_bar->setStyleSheet(progress_bar_style);
+ progress_bar->setRange(0, 1000);
+
+ button->setMinimumSize(100, 50);
+ button->setMaximumWidth(100);
+ button->setStyleSheet(button_style);
+ button->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Fixed);
+
+ button2->setMinimumSize(100, 50);
+ button2->setMaximumWidth(100);
+ button2->setStyleSheet(button_style);
+ button2->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Fixed);
+
+ QVBoxLayout* layout = new QVBoxLayout;
+ layout->addWidget(progress_bar);
+ layout->addWidget(button, 0, Qt::AlignHCenter);
+ layout->addWidget(button2, 0, Qt::AlignHCenter);
+ // 设置窗口布局为垂直布局管理器
+ centralWidget()->setLayout(layout);
+
+ connect(button, &QPushButton::clicked, this, &async_progress_bar::task);
+ connect(button2, &QPushButton::clicked, []{
+ QMessageBox::information(nullptr, "测试", "没有卡界面!");
+ });
+}
QMetaObject::invokeMethod
的 lambda 是在主线程运行的,通过显示的线程 ID 可以验证这一点。std::async
的 std::launch::async
参数强制异步执行任务,以确保任务在新线程中运行。C++11 的 std::this_thread::get_id()
返回的内部类 std::thread::id
没办法直接转换为 unsigned int
,我们就直接使用了 win32 的 API _Thrd_id()
了。如果您是 Linux 之类的环境,使用 POSIX 接口 pthread_self()
。
这个例子其实很好的展示了多线程异步的作用,因为有 UI,所以很直观,毕竟如果你不用线程,那么不就卡界面了,用了就没事。
建议下载并运行此项目,通过实际操作理解代码效果。同时,可以尝试修改代码,观察不同情况下 UI 的响应情况,以加深对异步任务处理的理解。
C++20 引入了信号量,对于那些熟悉操作系统或其它并发支持库的开发者来说,这个同步设施的概念应该不会感到陌生。信号量源自操作系统,是一个古老而广泛应用的同步设施,在各种编程语言中都有自己的抽象实现。然而,C++ 标准库对其的支持却来得很晚,在 C++20 中才得以引入。
信号量是一个非常轻量简单的同步设施,它维护一个计数,这个计数不能小于 0
。信号量提供两种基本操作:释放(增加计数)和等待(减少计数)。如果当前信号量的计数值为 0
,那么执行“等待”操作的线程将会一直阻塞,直到计数大于 0
,也就是其它线程执行了“释放”操作。
C++ 提供了两个信号量类型:std::counting_semaphore
与 std::binary_semaphore
,定义在 <semaphore>
中。
binary_semaphore
[3] 只是 counting_semaphore
的一个特化别名:
using binary_semaphore = counting_semaphore<1>;
好了,我们举一个简单的例子来使用一下:
// 全局二元信号量对象
+// 设置对象初始计数为 0
+std::binary_semaphore smph_signal_main_to_thread{ 0 };
+std::binary_semaphore smph_signal_thread_to_main{ 0 };
+
+void thread_proc() {
+ smph_signal_main_to_thread.acquire();
+ std::cout << "[线程] 获得信号" << std::endl;
+
+ std::this_thread::sleep_for(3s);
+
+ std::cout << "[线程] 发送信号\\n";
+ smph_signal_thread_to_main.release();
+}
+
+int main() {
+ std::jthread thr_worker{ thread_proc };
+
+ std::cout << "[主] 发送信号\\n";
+ smph_signal_main_to_thread.release();
+
+ smph_signal_thread_to_main.acquire();
+ std::cout << "[主] 获得信号\\n";
+}
运行结果:
[主] 发送信号
+[线程] 获得信号
+[线程] 发送信号
+[主] 获得信号
acquire
函数就是我们先前说的“等待”(原子地减少计数),release
函数就是"释放"(原子地增加计数)。
信号量常用于发信/提醒而非互斥,通过初始化该信号量为 0 从而阻塞尝试 acquire() 的接收者,直至提醒者通过调用 release(n) “发信”。在此方面可把信号量当作条件变量的替代品,通常它有更好的性能。
假设我们有一个 Web 服务器,它只能处理有限数量的并发请求。为了防止服务器过载,我们可以使用信号量来限制并发请求的数量。
// 定义一个信号量,最大并发数为 3
+std::counting_semaphore<3> semaphore{ 3 };
+
+void handle_request(int request_id) {
+ // 请求到达,尝试获取信号量
+ std::cout << "进入 handle_request 尝试获取信号量\\n";
+
+ semaphore.acquire();
+
+ std::cout << "成功获取信号量\\n";
+
+ // 此处延时三秒可以方便测试,会看到先输出 3 个“成功获取信号量”,因为只有三个线程能成功调用 acquire,剩余的会被阻塞
+ std::this_thread::sleep_for(3s);
+
+ // 模拟处理时间
+ std::random_device rd;
+ std::mt19937 gen{ rd() };
+ std::uniform_int_distribution<> dis(1, 5);
+ int processing_time = dis(gen);
+ std::this_thread::sleep_for(std::chrono::seconds(processing_time));
+
+ std::cout << std::format("请求 {} 已被处理\\n", request_id);
+
+ semaphore.release();
+}
+
+int main() {
+ // 模拟 10 个并发请求
+ std::vector<std::jthread> threads;
+ for (int i = 0; i < 10; ++i) {
+ threads.emplace_back(handle_request, i);
+ }
+}
运行测试。
这段代码很简单,以至于我们可以在这里来再说一条概念:
counting_semaphore
是一个轻量同步原语,能控制对共享资源的访问。不同于 std::mutex,counting_semaphore
允许同一资源进行多个并发的访问,至少允许 LeastMaxValue
个同时访问者[4]。binary_semaphore
是 std::counting_semaphore
的特化的别名,其 LeastMaxValue
为 1。LeastMaxValue
是我们设置的非类型模板参数,意思是信号量维护的计数最大值。我们这段代码设置的是 3
,也就是允许 3 个同时访问者。
虽然说是说有 LeastMaxValue 可能不是最大,但是我们通常不用在意这个事情,MSVC STL 的实现中 max 函数就是直接返回
LeastMaxValue
,将它视为信号量维护的计数最大值即可。
牢记信号量的基本的概念不变,计数的值不能小于 0
,如果当前信号量的计数值为 0
,那么执行“等待”(acquire)操作的线程将会一直阻塞。明白这点,那么就都不存在问题。
通过这种方式,可以有效控制 Web 服务器处理并发请求的数量,防止服务器过载。
闩 (latch) 与屏障 (barrier) 是线程协调机制,允许任何数量的线程阻塞直至期待数量的线程到达。闩不能重复使用,而屏障则可以。
std::latch
:单次使用的线程屏障std::barrier
:可复用的线程屏障它们定义在标头 <latch>
与 <barrier>
。
与信号量类似,屏障也是一种古老而广泛应用的同步机制。许多系统 API 提供了对屏障机制的支持,例如 POSIX 和 Win32。此外,OpenMP 也提供了屏障机制来支持多线程编程。
std::latch
“闩” ,中文语境一般说“门闩” 是指门背后用来关门的棍子。不过不用在意,在 C++ 中的意思就是先前说的:单次使用的线程屏障。
latch
类维护着一个 std::ptrdiff_t
类型的计数[5],且只能减少计数,无法增加计数。在创建对象的时候初始化计数器的值。线程可以阻塞,直到 latch 对象的计数减少到零。由于无法增加计数,这使得 latch
成为一种单次使用的屏障。
std::latch work_start{ 3 };
+
+void work(){
+ std::cout << "等待其它线程执行\\n";
+ work_start.wait(); // 等待计数为 0
+ std::cout << "任务开始执行\\n";
+}
+
+int main(){
+ std::jthread thread{ work };
+ std::this_thread::sleep_for(3s);
+ std::cout << "休眠结束\\n";
+ work_start.count_down(); // 默认值是 1 减少计数 1
+ work_start.count_down(2); // 传递参数 2 减少计数 2
+}
运行结果:
等待其它线程执行
+休眠结束
+任务开始执行
在这个例子中,通过调用 wait
函数阻塞子线程,直到主线程调用 count_down
函数原子地将计数减至 0
,从而解除阻塞。这个例子清楚地展示了 latch
的使用,其逻辑比信号量更简单。
由于 latch
的计数不可增加,它的使用通常非常简单,可以用来划分任务执行的工作区间。例如:
std::latch latch{ 10 };
+
+void f(int id) {
+ //todo.. 脑补任务
+ std::this_thread::sleep_for(1s);
+ std::cout << std::format("线程 {} 执行完任务,开始等待其它线程执行到此处\\n", id);
+ latch.arrive_and_wait();
+ std::cout << std::format("线程 {} 彻底退出函数\\n", id);
+}
+
+int main() {
+ std::vector<std::jthread> threads;
+ for (int i = 0; i < 10; ++i) {
+ threads.emplace_back(f,i);
+ }
+}
运行测试。
arrive_and_wait
函数等价于:count_down(n); wait();
。也就是减少计数 + 等待。这意味着
必须等待所有线程执行到 latch.arrive_and_wait();
将 latch 的计数减少至 0
才能继续往下执行。这个示例非常直观地展示了如何使用 latch
来划分任务执行的工作区间。
由于 latch
的功能受限,通常用于简单直接的需求,不少情况很多同步设施都能完成你的需求,在这个时候请考虑使用尽可能功能最少的那一个。
std::barrier
上节我们学习了 std::latch
,本节内容也不会对你构成难度。
template< class CompletionFunction = /* 未指定 */ >
+class barrier;
CompletionFunction - 函数对象类型。
std::barrier
和 std::latch
最大的不同是,前者可以在阶段完成之后将计数重置为构造时传递的值,而后者只能减少计数。我们用一个非常简单直观的示例为你展示:
std::barrier barrier{ 10,
+ [n = 1]()mutable noexcept {std::cout << "\\t第" << n++ << "轮结束\\n"; }
+};
+
+void f(int start, int end){
+ for (int i = start; i <= end; ++i) {
+ std::osyncstream{ std::cout } << i << ' ';
+ barrier.arrive_and_wait(); // 减少计数并等待 解除阻塞时就重置计数并调用函数对象
+
+ std::this_thread::sleep_for(300ms);
+ }
+}
+
+int main(){
+ std::vector<std::jthread> threads;
+ for (int i = 0; i < 10; ++i) {
+ threads.emplace_back(f, i * 10 + 1, (i + 1) * 10);
+ }
+}
可能的运行结果:
1 21 11 31 41 51 61 71 81 91 第1轮结束
+12 2 22 32 42 52 62 72 92 82 第2轮结束
+13 63 73 33 23 53 83 93 43 3 第3轮结束
+14 44 24 34 94 74 64 4 84 54 第4轮结束
+5 95 15 45 75 25 55 65 35 85 第5轮结束
+6 46 16 26 56 96 86 66 76 36 第6轮结束
+47 17 57 97 87 67 77 7 27 37 第7轮结束
+38 8 28 78 68 88 98 58 18 48 第8轮结束
+9 39 29 69 89 99 59 19 79 49 第9轮结束
+30 40 70 10 90 50 60 20 80 100 第10轮结束
注意输出的规律,第一轮每个数字最后一位都是 1
,第二轮每个数字最后一位都是 2
……以此类推,因为我们分配给每个线程的输出任务就是如此,然后利用了屏障一轮一轮地打印。
arrive_and_wait
等价于 wait(arrive());
。原子地将期待计数减少 1,然后在当前阶段的同步点阻塞直至运行当前阶段的阶段完成步骤。
arrive_and_wait()
会在期待计数减少至 0
时调用我们构造 barrier 对象时传入的 lambda 表达式,并解除所有在阶段同步点上阻塞的线程。之后重置期待计数为构造中指定的值。屏障的一个阶段就完成了。
barrier
除了析构函数外的成员函数不会引起数据竞争。另外你可能注意到我们使用了 std::osyncstream
,它是 C++20 引入的,此处是确保输出流在多线程环境中同步,避免除数据竞争,而且将不以任何方式穿插或截断。
虽然
std::cout
的operator<<
调用是线程安全的,不会被打断,但多个operator<<
的调用在多线程环境中可能会交错,导致输出结果混乱,使用std::osyncstream
就可以解决这个问题。开发者可以尝试去除std::osyncstream
直接使用std::cout
,效果会非常明显。
使用 arrive
或 arrive_and_wait
减少的都是当前屏障计数,我们称作“期待计数”。不管如何减少计数,当完成一个阶段,就重置期待计数为构造中指定的值了。
标准库还提供一个函数 arrive_and_drop
可以改变重置的计数值:它将所有后继阶段的初始期待计数减少一,当前阶段的期待计数也减少一。
不用感到难以理解,我们来解释一下这个概念:
std::barrier barrier{ 4 }; // 初始化计数为 4 完成阶段重置计数也是 4
+barrier.arrive_and_wait(); // 当前计数减 1,不影响之后重置计数 4
+barrier.arrive_and_drop(); // 当前计数与重置之后的计数均减 1 完成阶段会重置计数为 3
arrive_and_drop
可以用来控制在需要的时候,让一些线程退出同步,如:
std::atomic_int active_threads{ 4 };
+std::barrier barrier{ 4,
+ [n = 1]() mutable noexcept {
+ std::cout << "\\t第" << n++ << "轮结束,活跃线程数: " << active_threads << '\\n';
+ }
+};
+
+void f(int thread_id) {
+ for (int i = 1; i <= 5; ++i) {
+ std::osyncstream{ std::cout } << "线程 " << thread_id << " 输出: " << i << '\\n';
+ if (i == 3 && thread_id == 2) { // 假设线程 ID 为 2 的线程在完成第三轮同步后退出
+ std::osyncstream{ std::cout } << "线程 " << thread_id << " 完成并退出\\n";
+ --active_threads; // 减少活跃线程数
+ barrier.arrive_and_drop(); // 减少当前计数 1,并减少重置计数 1
+ return;
+ }
+ barrier.arrive_and_wait(); // 减少计数并等待,解除阻塞时重置计数并调用函数对象
+ }
+}
+
+int main() {
+ std::vector<std::jthread> threads;
+ for (int i = 1; i <= 4; ++i) {
+ threads.emplace_back(f, i);
+ }
+}
运行测试。
初始线程有 4 个,线程 2 在执行了三轮同步便直接退出了,调用 arrive_and_drop
函数,下一个阶段的计数会重置为 3
,也就是执行完第三轮同步后只有三个活跃线程继续执行。查看输出结果,非常的直观。
这样,arrive_and_drop
的作用就非常明显了,使用也十分的简单。
最后请注意,我们的 lambda 表达式必须声明为 noexcept
,因为 std::barrier
要求其函数对象类型必须是不抛出异常的。即要求 std::is_nothrow_invocable_v<_Completion_function&>
为 true,见 MSVC STL。
std::barrier barrier{ 1,[] {} };
按照标准规定,这行代码会产生一个编译错误。因为传入的函数对象它不是 noexcept
的。不过,在 gcc 与 clang(即 libstdc++ 和 libc++)均可以通过编译,这是因为它们没有进行相应的检测,存在缺陷,为了代码的可维护性开发者应遵守标准规定,确保传入的函数对象是 noexcept
的。
在并发编程中,同步操作对于并发编程至关重要。如果没有同步,线程基本上就是独立的,因其任务之间的相关性,才可作为一个整体执行(比如第二章的并行求和)。本章讨论了多种用于同步操作的工具,包括条件变量、future、promise、package_task、信号量。同时,详细介绍了 C++ 时间库的知识,以使用并发支持库中的“限时等待”。还使用 CMake + Qt 构建了一个带有 UI 界面的示例,展示异步多线程的必要性。最后介绍了 C++20 引入的两种新的并发设施,信号量、闩与屏障。
在讨论了 C++ 中的高级工具之后,现在让我们来看看底层工具:C++ 内存模型与原子操作。
注:多个线程能在不同的 shared_ptr 对象上调用所有成员函数(包含复制构造函数与复制赋值)而不附加同步,即使这些实例是同一对象的副本且共享所有权也是如此。若多个执行线程访问同一 shared_ptr 对象而不同步,且任一线程使用 shared_ptr 的非 const 成员函数,则将出现数据竞争;std::atomic<shared_ptr>
能用于避免数据竞争。文档。 ↩︎
注:如果信号量只有二进制的 0 或 1,称为二进制信号量(binary semaphore),这就是这个类型名字的由来。 ↩︎
注:如其名所示,LeastMaxValue 是最小 的最大值,而非实际 最大值。静态成员函数 max()
可能产生大于 LeastMaxValue 的值。 ↩︎
注:通常的实现是直接保有一个 std::atomic<std::ptrdiff_t>
私有数据成员,以保证计数修改的原子性。原子类型在我们第五章的内容会详细展开。 ↩︎
\\"同步操作\\"是指在计算机科学和信息技术中的一种操作方式,其中不同的任务或操作按顺序执行,一个操作完成后才能开始下一个操作。在多线程编程中,各个任务通常需要通过同步设施进行相互协调和等待,以确保数据的一致性和正确性。
\\n本章的主要内容有:
\\n条件变量
\\nstd::future
等待异步任务
在规定时间内等待
\\nQt 实现异步任务的示例
\\n其它 C++20 同步设施:信号量、闩与屏障
\\n我相信,已经阅读到这里的各位,不会对“线程池”这个词感到陌生。大部分开发者早就自己使用、学习,乃至实现过线程池。那不如我们先来进行一下基础的名词解释。
“线程”没什么好说的,是 CPU 调度的最小单位,也是操作系统的一种抽象资源。
“池”?水池装着水,线程池则是装着线程,是一种抽象的指代。
抽象的来说,可以当做是一个池子中存放了一堆线程,故称作线程池。简而言之,线程池是指代一组预先创建的、可以复用的线程集合。这些线程由线程池管理,用于执行多个任务而无需频繁地创建和销毁线程。
',7)),h(a,{id:"mermaid-25",code:"eJyFk09Kw0AYxfc5RS6QQOZPsnPlEdyFLhRFVyKKJxDFqhtFUEGwBdFuBLttS3uZTtPeou28oXzfJEOyad78XpP3vpmcXh5enMUH+1G8ua6uj6Cr0awaPC6GPbu8vbCSeVp4WnpaeVrvdJqm9v7k/DiyN/Px2Dz0V+9f5v6tpKJj8fL/xox+zMcgTpI9lqaJIFcTQcImgqxNRNOEmQU7GyEiSGSQqCDRQZIHSeERi9ysLFp0f5f9J1ekZApjduOrmQUzC2pGOYolM0tqRl+KFTMrasYIKNbMrDtoyHrY/8wnz9VnLyvxW/11zfQWT2Y1iFe0eNESHtniRUl4VIsXHeHRnje851aZ1+/V3Yvb3pIpvIctxUnqfTihg5GzIef1zAjjZkxTeuevllKwlO4UsSWaEh9x6EQWLGVRT4kwbnejaA0HonQG"}),s[1]||(s[1]=i(`这是一个典型的线程池结构。线程池包含一个任务队列,当有新任务加入时,调度器会将任务分配给线程池中的空闲线程进行执行。线程在执行完任务后会进入休眠状态,等待调度器的下一次唤醒。当有新的任务加入队列,并且有线程处于休眠状态时,调度器会唤醒休眠的线程,并分配新的任务给它们执行。线程执行完新任务后,会再次进入休眠状态,直到有新的任务到来,调度器才可能会再次唤醒它们。
图中线程1 就是被调度器分配了任务1,执行完毕后休眠,然而新任务的到来让调度器再次将它唤醒,去执行任务6,执行完毕后继续休眠。
使用线程池的益处我们已经加粗了,然而这其实并不是“线程池”独有的,任何创建和销毁存在较大开销的设施,都可以进行所谓的“池化”。
常见的还有:套接字连接池、数据库连接池、内存池、对象池。
了解以上这些基础概念是第一步也是最后一步,随着水平的提升,对这些概念的理解也会逐渐提升。
在了解了线程池的基本概念与运行逻辑后,我们不用着急就尝试实现。我们可以先来聊一聊,使用一下市面上常见的那些 C++ 线程池设施,了解它们提供的功能,接口设计的方式。
boost::asio::thread_pool
boost::asio::thread_pool
是 Boost.Asio
库提供的一种线程池实现。
Asio 是一个跨平台的 C++ 库,用于网络和低级 I/O 编程,使用 现代C++ 方法为开发人员提供一致的异步模型。
使用方法:
创建线程池对象,指定或让 Asio 自动决定线程数量。
提交任务:通过 boost::asio::post
函数模板提交任务到线程池中。
阻塞,直到池中的线程完成任务。
#include <boost/asio.hpp>
+#include <iostream>
+
+std::mutex m;
+
+void print_task(int n) {
+ std::lock_guard<std::mutex> lc{ m };
+ std::cout << "Task " << n << " is running on thr: " <<
+ std::this_thread::get_id() << '\\n';
+}
+
+int main() {
+ boost::asio::thread_pool pool{ 4 }; // 创建一个包含 4 个线程的线程池
+
+ for (int i = 0; i < 10; ++i) {
+ boost::asio::post(pool, [i] { print_task(i); });
+ }
+
+ pool.join(); // 等待所有任务执行完成
+}
运行测试。
创建线程池时,指定线程数量,线程池会创建对应数量的线程。
使用 boost::asio::post
提交任务,任务会被添加到任务队列中。
线程池中的线程会从任务队列中取出任务并执行,任务执行完毕后,线程继续取下一个任务或者休眠。
调用 join 方法等待所有任务执行完毕并关闭线程池。
如果我们不自己指明线程池的线程数量,那么 Asio 会根据函数 default_thread_pool_size
计算并返回一个线程池的默认线程数量。它根据系统的硬件并发能力来决定使用的线程数,通常是硬件并发能力的两倍。
inline long default_thread_pool_size()
+{
+ std::size_t num_threads = thread::hardware_concurrency() * 2;
+ num_threads = num_threads == 0 ? 2 : num_threads;
+ return static_cast<long>(num_threads);
+}
+
+thread_pool::thread_pool()
+ : scheduler_(add_scheduler(new detail::scheduler(*this, 0, false))),
+ num_threads_(detail::default_thread_pool_size())
代码很简单,就是 thread::hardware_concurrency() * 2
而已,至于下面的判断是因为 std::thread::hardware_concurrency()
在某些特殊情况下可能返回 0
(例如硬件并发能力无法被检测时),那那将 num_threads
设置为 2,确保线程池至少有 2 个线程。
Boost.Asio 的线程池对象在析构时会自动调用相关的清理方法,但你也可以手动进行控制。
thread_pool::~thread_pool()
+{
+ stop(); // 停止接收新任务
+ join(); // 等待所有线程完成
+ shutdown(); // 最终清理,释放资源
+}
stop
:修改内部的标志位存在使得线程池能够识别何时需要停止接收新的任务,以及关闭还没开始执行的任务,然后唤醒所有线程。join()
:等待所有线程完成它们的工作,确保所有线程都已终止。shutdown()
:进行最终的清理,释放资源,确保线程池的完全清理和资源的正确释放此处可阅读部分源码,帮助理解与记忆
析构函数先调用了 stop()
,然后再进行 join()
。那如果我们没有提前显式调用 join()
成员函数,可能导致一些任务没有执行,析构函数并不会等待所有任务执行完毕:
boost::asio::thread_pool pool{ 4 };
+
+for (int i = 0; i < 10; ++i) {
+ boost::asio::post(pool, [i] { print_task(i); });
+}
运行测试。
因为析构函数并不是阻塞直到执行完所有任务,而是先停止,再 join()
以及 shutdown()
。
Boost.Asio
提供的线程池使用十分简单,接口高度封装,几乎无需关心底层具体实现,易于使用。
我们的操作几乎只需创建线程池对象、将任务加入线程池、在需要时调用 join()
。
boost::asio::thread_pool pool{4}; // 创建线程池
+boost::asio::post(pool, task); // 将任务加入线程池
+pool.join(); // 等待任务完成 (或者析构自动调用)
QThreadPool
QThreadPool
是 Qt 提供的线程池实现,它是用来管理自家的 QThreads
的集合。
#include <QCoreApplication>
+#include <QThreadPool>
+#include <QRunnable>
+#include <QDebug>
+
+struct MyTask : public QRunnable{
+ void run() override {
+ qDebug() << "🐢🐢🐢🐢🐢";
+ }
+};
+
+int main(int argc, char *argv[]){
+ QCoreApplication app(argc, argv);
+
+ QThreadPool *threadPool = QThreadPool::globalInstance();
+
+ // 线程池最大线程数
+ qDebug()<< threadPool->maxThreadCount();
+
+ for (int i = 0; i < 10; ++i) {
+ MyTask *task = new MyTask{};
+ threadPool->start(task);
+ }
+ // 当前活跃线程数 10
+ qDebug()<<threadPool->activeThreadCount();
+
+ app.exec();
+}
与 Asio.thread_pool
不同,QThreadPool
采用单例模式,通过静态成员函数 QThreadPool::globalInstance()
获取对象实例(不过也可以自己创建)。默认情况下,QThreadPool
线程池的最大线程数为当前硬件支持的并发线程数,例如在我的硬件上为 20
,这点也和 Asio.thread_pool
不同。
QThreadPool
依赖于 Qt 的事件循环,因此我们使用了 QCoreApplication
。
而将任务添加到线程池中的做法非常古老原始,我们需要自定义一个类型继承并重写虚函数 run
,创建任务对象,然后将任务对象传递给线程池的 start
方法。
这种方法过于原始,如果读者学过
java
相信也不会陌生。我们实现的线程池不会是如此。
在 Qt6,引入了一个 start
的重载版本:
template <typename Callable, QRunnable::if_callable<Callable>>
+void QThreadPool::start(Callable &&functionToRun, int priority)
+{
+ start(QRunnable::create(std::forward<Callable>(functionToRun)), priority);
+}
它相当于是对start
原始版本的:
void start(QRunnable *runnable, int priority = 0);
源码。
进行的一个包装,以支持任何的可调用(Callable)类型,而无需再繁琐的继承重写 run
函数。
threadPool->start([=]{
+ qDebug()<<QString("thread id %1").arg(i);
+});
QThradPool
还支持手动控制任务优先级。通过调用 start
成员函数,将任务传递给线程池后可以再指明执行策略。
enum QThread::Priority
枚举类型表示操作系统应如何调度新创建的线程。
常量 | 值 | 描述 |
---|---|---|
QThread::IdlePriority | 0 | 仅在没有其他线程运行时调度。 |
QThread::LowestPriority | 1 | 调度频率低于 LowPriority。 |
QThread::LowPriority | 2 | 调度频率低于 NormalPriority。 |
QThread::NormalPriority | 3 | 操作系统的默认优先级。 |
QThread::HighPriority | 4 | 调度频率高于 NormalPriority。 |
QThread::HighestPriority | 5 | 调度频率高于 HighPriority。 |
QThread::TimeCriticalPriority | 6 | 尽可能频繁地调度。 |
QThread::InheritPriority | 7 | 使用与创建线程相同的优先级。 这是默认值。 |
到此也就足够了,虽然还有不少接口没有介绍,不过也都没什么特别的了。
实现一个普通的能够满足日常开发需求的线程池实际上非常简单,只需要不到一百行代码。
“普通的能够满足日常开发需求的”
其实绝大部分开发者使用线程池,只是为了不重复多次创建线程罢了。所以只需要一个提供一个外部接口,可以传入任务到任务队列,然后安排线程去执行。无非是使用条件变量、互斥量、原子标志位,这些东西,就足够编写一个满足绝大部分业务需求的线程池。
我们先编写一个最基础的线程池,首先确定它的数据成员:
class ThreadPool {
+ std::mutex mutex_;
+ std::condition_variable cv_;
+ std::atomic<bool> stop_;
+ std::atomic<std::size_t> num_threads_;
+ std::queue<Task> tasks_;
+ std::vector<std::thread> pool_;
+};
std::mutex mutex_
std::condition_variable cv_
std::atomic<bool> stop_
std::atomic<std::size_t> num_threads_
std::queue<Task> tasks_
std::vector<std::thread> pool_
标头依赖:
#include <iostream>
+#include <thread>
+#include <mutex>
+#include <condition_variable>
+#include <future>
+#include <atomic>
+#include <queue>
+#include <vector>
+#include <syncstream>
+#include <functional>
提供构造析构函数,以及一些外部接口:submit()
、start()
、stop()
、join()
,也就完成了:
inline std::size_t default_thread_pool_size()noexcept {
+ std::size_t num_threads = std::thread::hardware_concurrency() * 2;
+ num_threads = num_threads == 0 ? 2 : num_threads;
+ return num_threads;
+}
+
+class ThreadPool {
+public:
+ using Task = std::packaged_task<void()>;
+
+ ThreadPool(const ThreadPool&) = delete;
+ ThreadPool& operator=(const ThreadPool&) = delete;
+
+ ThreadPool(std::size_t num_thread = default_thread_pool_size())
+ : stop_{ false }, num_threads_{ num_thread } {
+ start();
+ }
+
+ ~ThreadPool() {
+ stop();
+ }
+
+ void stop() {
+ stop_.store(true);
+ cv_.notify_all();
+ for (auto& thread : pool_) {
+ if (thread.joinable()) {
+ thread.join();
+ }
+ }
+ pool_.clear();
+ }
+
+ template<typename F, typename... Args>
+ std::future<std::invoke_result_t<std::decay_t<F>, std::decay_t<Args>...>> submit(F&& f, Args&&...args) {
+ using RetType = std::invoke_result_t<std::decay_t<F>, std::decay_t<Args>...>;
+ if (stop_.load()) {
+ throw std::runtime_error("ThreadPool is stopped");
+ }
+
+ auto task = std::make_shared<std::packaged_task<RetType()>>(
+ std::bind(std::forward<F>(f), std::forward<Args>(args)...));
+ std::future<RetType> ret = task->get_future();
+
+ {
+ std::lock_guard<std::mutex> lc{ mutex_ };
+ tasks_.emplace([task] {(*task)(); });
+ }
+ cv_.notify_one();
+ return ret;
+ }
+
+ void start() {
+ for (std::size_t i = 0; i < num_threads_; ++i) {
+ pool_.emplace_back([this] {
+ while (!stop_) {
+ Task task;
+ {
+ std::unique_lock<std::mutex> lc{ mutex_ };
+ cv_.wait(lc, [this] {return stop_ || !tasks_.empty(); });
+ if (tasks_.empty())
+ return;
+ task = std::move(tasks_.front());
+ tasks_.pop();
+ }
+ task();
+ }
+ });
+ }
+ }
+
+private:
+ std::mutex mutex_;
+ std::condition_variable cv_;
+ std::atomic<bool> stop_;
+ std::atomic<std::size_t> num_threads_;
+ std::queue<Task> tasks_;
+ std::vector<std::thread> pool_;
+};
测试 demo:
int main() {
+ ThreadPool pool{ 4 }; // 创建一个有 4 个线程的线程池
+ std::vector<std::future<int>> futures; // future 集合,获取返回值
+
+ for (int i = 0; i < 10; ++i) {
+ futures.emplace_back(pool.submit(print_task, i));
+ }
+
+ for (int i = 0; i < 10; ++i) {
+ futures.emplace_back(pool.submit(print_task2, i));
+ }
+
+ int sum = 0;
+ for (auto& future : futures) {
+ sum += future.get(); // get() 成员函数 阻塞到任务执行完毕,获取返回值
+ }
+ std::cout << "sum: " << sum << '\\n';
+} // 析构自动 stop()
可能的运行结果:
Task 0 is running on thr: 6900
+Task 1 is running on thr: 36304
+Task 5 is running on thr: 36304
+Task 3 is running on thr: 6900
+Task 7 is running on thr: 6900
+Task 2 is running on thr: 29376
+Task 6 is running on thr: 36304
+Task 4 is running on thr: 31416
+🐢🐢🐢 1 🐉🐉🐉
+Task 9 is running on thr: 29376
+🐢🐢🐢 0 🐉🐉🐉
+Task 8 is running on thr: 6900
+🐢🐢🐢 2 🐉🐉🐉
+🐢🐢🐢 6 🐉🐉🐉
+🐢🐢🐢 4 🐉🐉🐉
+🐢🐢🐢 5 🐉🐉🐉
+🐢🐢🐢 3 🐉🐉🐉
+🐢🐢🐢 7 🐉🐉🐉
+🐢🐢🐢 8 🐉🐉🐉
+🐢🐢🐢 9 🐉🐉🐉
+sum: 90
如果等待线程池对象调用析构函数,那么效果如同
asio::thread_pool
,会先进行stop
,这可能导致一些任务无法执行。不过我们在最后循环遍历了futures
,调用get()
成员函数,不存在这个问题。
它支持任意可调用类型,当然也包括非静态成员函数。我们使用了 std::decay_t
,所以参数的传递其实是按值复制,而不是引用传递,这一点和大部分库的设计一致。示例如下:
struct X {
+ void f(const int& n) const {
+ std::osyncstream{ std::cout } << &n << '\\n';
+ }
+};
+
+int main() {
+ ThreadPool pool{ 4 }; // 创建一个有 4 个线程的线程池
+
+ X x;
+ int n = 6;
+ std::cout << &n << '\\n';
+ auto t = pool.submit(&X::f, &x, n); // 默认复制,地址不同
+ auto t2 = pool.submit(&X::f, &x, std::ref(n));
+ t.wait();
+ t2.wait();
+} // 析构自动 stop()
运行测试。
我们的线程池的 submit
成员函数在传递参数的行为上,与先前介绍的 std::thread
和 std::async
等设施基本一致。
我们稍微介绍线程池的接口:
构造函数和析构函数:
构造函数:初始化线程池并启动线程。
析构函数:停止线程池并等待所有线程结束。
外部接口:
stop()
:停止线程池,通知所有线程退出(不会等待所有任务执行完毕)。submit()
:将任务提交到任务队列,并返回一个std::future
对象用于获取任务结果以及确保任务执行完毕。start()
:启动线程池,创建并启动指定数量的线程。我们并没有提供一个功能强大的所谓的“调度器”,我们只是利用条件变量和互斥量,让操作系统自行调度而已,它并不具备设置任务优先级之类的调度功能。
当然,你可能还希望我们的线程池具备更多功能或改进,比如控制任务优先级、设置最大线程数量、返回当前活跃线程数等。此外,异常处理也是一个值得考虑的方面。
有些功能实现起来非常简单,而有些则需要更多的思考和设计。不过,这些功能超出了本次讲解的范围。如果有兴趣,可以尝试自行优化我们提供的线程池实现,添加更多的功能。我们给出的线程池实现简单完善且直观,用来学习再好不过。
在本章中我们详细的介绍了:
线程池的基本概念。
市面上常见的线程池的设计与使用, boost::asio::thread_pool
、QThreadPool
。
实现一个简易的线程池。
总体而言,内容并不构成太大的难度。
课后作业:自己实现一个线程池,可以参考我们给出的线程池实现增加功能,提交到 homework
文件夹中。
我相信,已经阅读到这里的各位,不会对“线程池”这个词感到陌生。大部分开发者早就自己使用、学习,乃至实现过线程池。那不如我们先来进行一下基础的名词解释。
\\n“线程”没什么好说的,是 CPU 调度的最小单位,也是操作系统的一种抽象资源。
\\n“池”?水池装着水,线程池则是装着线程,是一种抽象的指代。
\\n抽象的来说,可以当做是一个池子中存放了一堆线程,故称作线程池。简而言之,线程池是指代一组预先创建的、可以复用的线程集合。这些线程由线程池管理,用于执行多个任务而无需频繁地创建和销毁线程。
"}');export{g as comp,y as data}; diff --git "a/assets/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.html-QZXnSagT.js" "b/assets/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.html-QZXnSagT.js" new file mode 100644 index 00000000..c3f945dd --- /dev/null +++ "b/assets/05\345\206\205\345\255\230\346\250\241\345\236\213\344\270\216\345\216\237\345\255\220\346\223\215\344\275\234.html-QZXnSagT.js" @@ -0,0 +1,258 @@ +import{_ as l}from"./plugin-vue_export-helper-DlAUqK2U.js";import{c as t,d as a,f as n,a as i,b as e,o as h}from"./app-Ca-A-iaw.js";const k={};function p(d,s){return h(),t("div",null,[s[0]||(s[0]=a(`内存模型定义了多线程程序中,读写操作如何在不同线程之间可见,以及这些操作在何种顺序下执行。内存模型确保程序的行为在并发环境下是可预测的。
原子操作即不可分割的操作。系统的所有线程,不可能观察到原子操作完成了一半。
最基础的概念就是如此,这里不再过多赘述,后续还会详细展开内存模型的问题。
int a = 0;
+void f(){
+ ++a;
+}
显然,++a
是非原子操作,也就是说在多线程中可能会被另一个线程观察到只完成一半。
a
的值。a
执行递增操作,但还未完成。a
的值。线程 C 到底读取到多少不确定,a 的值是多少也不确定。显然,这构成了数据竞争,出现了未定义行为。
在之前的内容中,我们讲述了使用很多同步设施,如互斥量,来保护共享资源。
std::mutex m;
+void f() {
+ std::lock_guard<std::mutex> lc{ m };
+ ++a;
+}
通过互斥量的保护,即使 ++a
本身不是原子操作,逻辑上也可视为原子操作。互斥量确保了对共享资源的读写是线程安全的,避免了数据竞争问题。
不过这显然不是我们的重点。我们想要的是一种原子类型,它的所有操作都直接是原子的,不需要额外的同步设施进行保护。C++11 引入了原子类型 std::atomic
,在下节我们会详细讲解。
std::atomic
标准原子类型定义在头文件 <atomic>
中。这些类型的操作都是原子的,语言定义中只有这些类型的操作是原子的,虽然也可以用互斥量来模拟原子操作(见上文)。
标准原子类型的实现通常包括一个 is_lock_free()
成员函数,允许用户查询特定原子类型的操作是否是通过直接的原子指令实现(返回 true),还是通过锁来实现(返回 false)。
如果一个线程写入原子对象,同时另一线程从它读取,那么行为有良好定义(数据竞争的细节见内存模型)。
原子操作可以在一些时候代替互斥量,来进行同步操作,也能带来更高的性能。但是如果它的内部使用互斥量实现,那么不可能有性能的提升。
在 C++17 中,所有原子类型都有一个 static constexpr
的数据成员 is_always_lock_free
。如果当前环境上的原子类型 X 是无锁类型,那么 X::is_always_lock_free
将返回 true
。例如:
std::atomic<int>::is_always_lock_free // true 或 false
标准库还提供了一组宏 ATOMIC_xxx_LOCK_FREE
,在编译时对各种整数原子类型是否无锁进行判断。
// (C++11 起)
+#define ATOMIC_BOOL_LOCK_FREE /* 未指定 */
+#define ATOMIC_CHAR_LOCK_FREE /* 未指定 */
+#define ATOMIC_CHAR16_T_LOCK_FREE /* 未指定 */
+#define ATOMIC_CHAR32_T_LOCK_FREE /* 未指定 */
+#define ATOMIC_WCHAR_T_LOCK_FREE /* 未指定 */
+#define ATOMIC_SHORT_LOCK_FREE /* 未指定 */
+#define ATOMIC_INT_LOCK_FREE /* 未指定 */
+#define ATOMIC_LONG_LOCK_FREE /* 未指定 */
+#define ATOMIC_LLONG_LOCK_FREE /* 未指定 */
+#define ATOMIC_POINTER_LOCK_FREE /* 未指定 */
+// (C++20 起)
+#define ATOMIC_CHAR8_T_LOCK_FREE /* 未指定 */
我们可以使用这些宏来对代码进行编译时的优化和检查,以确保在特定平台上原子操作的性能。例如,如果我们知道某些操作在目标平台上是无锁的,那么我们可以利用这一点进行性能优化。如果这些操作在目标平台上是有锁的,我们可能会选择其它同步机制。
// 检查 std::atomic<int> 是否总是无锁
+if constexpr(std::atomic<int>::is_always_lock_free) {
+ std::cout << "当前环境 std::atomic<int> 始终是无锁" << std::endl;
+}
+else {
+ std::cout << "当前环境 std::atomic<int> 并不总是无锁" << std::endl;
+}
+
+// 使用 ATOMIC_INT_LOCK_FREE 宏进行编译时检查
+#if ATOMIC_INT_LOCK_FREE == 2
+ std::cout << "int 类型的原子操作一定无锁的。" << std::endl;
+#elif ATOMIC_INT_LOCK_FREE == 1
+ std::cout << "int 类型的原子操作有时是无锁的。" << std::endl;
+#else
+ std::cout << "int 类型的原子操作一定有锁的。" << std::endl;
+#endif
运行测试。
如你所见,我们写了一个简单的示例,展示了如何使用 C++17 的静态数据成员 is_always_lock_free
和预处理宏来让程序执行不同的代码。
因为 is_always_lock_free
是编译期常量,所以我们可以使用 C++17 引入的 constexpr if
,它可以在编译阶段进行决策,避免了运行时的判断开销,提高了性能。
宏则更是简单了,最基本的预处理器判断,在预处理阶段就选择编译合适的代码。
在实际应用中,如果一个类型的原子操作总是无锁的,我们可以更放心地在性能关键的代码路径中使用它。例如,在高频交易系统、实时系统或者其它需要高并发性能的场景中,无锁的原子操作可以显著减少锁的开销和竞争,提高系统的吞吐量和响应时间。
另一方面,如果发现某些原子类型在目标平台上是有锁的,我们可以考虑以下优化策略:
当然,其实很多时候根本没这种性能的担忧,我们很多时候使用原子对象只是为了简单方便,比如 std::atomic<bool>
表示状态、std::atomic<int>
进行计数等。即使它们是用了锁,那也是封装好了的,起码用着方便,而不需要在代码中引入额外的互斥量来保护,更加简洁。这也是很正常的需求,各位不但要考虑程序的性能,同时也要考虑代码的简洁性、易用性。即使使用原子类型无法带来效率的提升,那也没有负提升。
除了直接使用 std::atomic
模板外,也可以使用原子类型的别名。这个数量非常之多,见 MSVC STL。
对于标准内建类型的别名,就是在原子类型的类型名前面加上 atomic_
的前缀:atomic_T
。不过 signed
缩写 s
、unsigned
缩写 u
、long long
缩写 llong
。
using atomic_char = atomic<char>;
+using atomic_schar = atomic<signed char>;
+using atomic_uchar = atomic<unsigned char>;
+using atomic_short = atomic<short>;
+using atomic_ushort = atomic<unsigned short>;
+using atomic_int = atomic<int>;
+using atomic_uint = atomic<unsigned int>;
+using atomic_long = atomic<long>;
+using atomic_ulong = atomic<unsigned long>;
+using atomic_llong = atomic<long long>;
+using atomic_ullong = atomic<unsigned long long>;
通常 std::atomic
对象不可进行复制、移动、赋值,因为它们的复制构造与复制赋值运算符被定义为弃置的。不过可以隐式转换成对应的内置类型,因为它有转换函数。
atomic(const atomic&) = delete;
+atomic& operator=(const atomic&) = delete;
+operator T() const noexcept;
可以使用 load()
、store()
、exchange()
、compare_exchange_weak()
和 compare_exchange_strong()
等成员函数对 std::atomic
进行操作。如果是整数类型的特化,还支持 ++
、--
、+=
、-=
、&=
、|=
、^=
、fetch_add
、fetch_sub
等操作方式。在后面详细的展开使用。
std::atomic
类模板不仅只能使用标准库为我们定义的特化类型,我们也完全可以自定义类型创建对应的原子对象。不过因为是通用模板,操作仅限 load()
、store()
、exchange()
、compare_exchange_weak()
、 compare_exchange_strong()
,以及一个转换函数。
模板 std::atomic
可用任何满足可复制构造 (CopyConstructible)及可复制赋值 (CopyAssignable)的可平凡复制 (TriviallyCopyable)类型 T
实例化。
struct trivial_type {
+ int x{};
+ float y{};
+
+ trivial_type() {}
+
+ trivial_type(int a, float b) : x{ a }, y{ b } {}
+
+ trivial_type(const trivial_type& other) = default;
+
+ trivial_type& operator=(const trivial_type& other) = default;
+
+ ~trivial_type() = default;
+};
验证自己的类型是否满足 std::atomic
要求,我们可以使用静态断言:
static_assert(std::is_trivially_copyable<trivial_type>::value, "");
+static_assert(std::is_copy_constructible<trivial_type>::value, "");
+static_assert(std::is_move_constructible<trivial_type>::value, "");
+static_assert(std::is_copy_assignable<trivial_type>::value, "");
+static_assert(std::is_move_assignable<trivial_type>::value, "");
程序能通过编译即代表满足要求。如果不满足要求,静态断言求值中第一个表达式求值为 false,则编译错误。显然我们的类型满足要求,我们可以尝试使用一下它:
// 创建一个 std::atomic<trivial_type> 对象
+std::atomic<trivial_type> atomic_my_type { trivial_type{ 10, 20.5f } };
+
+// 使用 store 和 load 操作来设置和获取值
+trivial_type new_value{ 30, 40.5f };
+atomic_my_type.store(new_value);
+
+trivial_type loadedValue = atomic_my_type.load();
+std::cout << "x: " << loadedValue.x << ", y: " << loadedValue.y << std::endl;
+
+// 使用 exchange 操作
+trivial_type exchanged_value = atomic_my_type.exchange(trivial_type{ 50, 60.5f });
+std::cout << "交换前的 x: " << exchanged_value.x
+ << ", 交换前的 y: " << exchanged_value.y << std::endl;
+std::cout << "交换后的 x: " << atomic_my_type.load().x
+ << ", 交换后的 y: " << atomic_my_type.load().y << std::endl;
运行测试。
没有问题,不过其实我们的 trivial_type
直接改成:
struct trivial_type {
+ int x;
+ float y;
+};
运行测试。
也是完全可以的,满足要求。先前只是为了展示一下显式写明的情况。
原子类型的每个操作函数,都有一个内存序参数,这个参数可以用来指定执行顺序,在后面的内容会详细讲述,现在只需要知道操作分为三类:
Store 操作(存储操作):可选的内存序包括 memory_order_relaxed
、memory_order_release
、memory_order_seq_cst
。
Load 操作(加载操作):可选的内存序包括 memory_order_relaxed
、memory_order_consume
、memory_order_acquire
、memory_order_seq_cst
。
Read-modify-write(读-改-写)操作:可选的内存序包括 memory_order_relaxed
、memory_order_consume
、memory_order_acquire
、memory_order_release
、memory_order_acq_rel
、memory_order_seq_cst
。
本节主要广泛介绍
std::atomic
,而未展开具体使用。在后续章节中,我们将更详细地讨论一些版本,如std::atomic<bool>
,并介绍其成员函数和使用方法。最后强调一下:任何 std::atomic 类型,初始化不是原子操作。
st::atomic_flag
std::atomic_flag
是最简单的原子类型,这个类型的对象可以在两个状态间切换:设置(true)和清除(false)。它很简单,通常只是用作构建一些库设施,不会单独使用或直接面向普通开发者。
在 C++20 之前,std::atomic_flag
类型的对象需要以 ATOMIC_FLAG_INIT
初始化,可以确保此时对象处于
"清除"(false)状态。
std::atomic_flag f = ATOMIC_FLAG_INIT;
在 C++20
中 std::atomic_flag
的默认构造函数保证对象为“清除”(false)状态,就不再需要使用 ATOMIC_FLAG_INIT
。
ATOMIC_FLAG_INIT
其实并不是什么复杂的东西,它在不同的标准库实现中只是简单的初始化:在 MSVC STL
它只是一个 {}
,在 libstdc++
与 libc++
它只是一个 { 0 }
。也就是说我们可以这样初始化:
std::atomic_flag f ATOMIC_FLAG_INIT;
+std::atomic_flag f2 = {};
+std::atomic_flag f3{};
+std::atomic_flag f4{ 0 };
使用 ATOMIC_FLAG_INIT 宏只是为了统一,我们知道即可。
当标志对象已初始化,它只能做三件事情:销毁、清除、设置。这些操作对应的函数分别是:
clear()
(清除):将标志对象的状态原子地更改为清除(false)test_and_set
(测试并设置):将标志对象的状态原子地更改为设置(true),并返回它先前保有的值。每个操作都可以指定内存顺序。clear()
是一个“读-改-写”操作,可以应用任何内存顺序。默认的内存顺序是 memory_order_seq_cst
。例如:
f.clear(std::memory_order_release);
+bool r = f.test_and_set();
将 f
的状态原子地更改为清除(false),指明 memory_order_release
内存序。
将 f
的状态原子地更改为设置(true),并返回它先前保有的值给 r
。使用默认的 memory_order_seq_cst
内存序。
不用着急,这里还不是详细展开聊内存序的时候。
std::atomic_flag
不可复制不可移动不可赋值。这不是 std::atomic_flag
特有的,而是所有原子类型共有的属性。原子类型的所有操作都是原子的,而赋值和复制涉及两个对象,破坏了操作的原子性。复制构造和复制赋值会先读取第一个对象的值,然后再写入另一个对象。对于两个独立的对象,这里实际上有两个独立的操作,合并这两个操作无法保证其原子性。因此,这些操作是不被允许的。
有限的特性使得 std::atomic_flag
非常适合用作制作自旋锁。
自旋锁可以理解为一种忙等锁,因为它在等待锁的过程中不会主动放弃 CPU,而是持续检查锁的状态。
与此相对,
std::mutex
互斥量是一种睡眠锁。当线程请求锁(lock()
)而未能获取时,它会放弃 CPU 时间片,让其他线程得以执行,从而有效利用系统资源。从性能上看,自旋锁的响应更快,但是睡眠锁更加节省资源,高效。
class spinlock_mutex {
+ std::atomic_flag flag{};
+public:
+ spinlock_mutex()noexcept = default;
+ void lock()noexcept {
+ while (flag.test_and_set(std::memory_order_acquire));
+ }
+
+ void unlock()noexcept {
+ flag.clear(std::memory_order_release);
+ }
+};
我们可以简单的使用测试一下,它是有效的:
spinlock_mutex m;
+
+void f(){
+ std::lock_guard<spinlock_mutex> lc{ m };
+ std::cout << "😅😅" << "❤️❤️\\n";
+}
运行测试。
稍微聊一下原理,我们的 spinlock_mutex
对象中存储的 flag
对象在默认构造时是清除 (false
) 状态。在 lock()
函数中调用 test_and_set
函数,它是原子的,只有一个线程能成功调用并将 flag
的状态原子地更改为设置 (true
),并返回它先前的值 (false
)。此时,该线程成功获取了锁,退出循环。
当 flag
对象的状态为设置 (true
) 时,其它线程调用 test_and_set
函数会返回 true
,导致它们继续在循环中自旋,无法退出。直到先前持有锁的线程调用 unlock()
函数,将 flag
对象的状态原子地更改为清除 (false
) 状态。此时,等待的线程中会有一个线程成功调用 test_and_set
返回 false
,然后退出循环,成功获取锁。
值得注意的是,我们只是稍微的讲一下使用
std::atomic_flag
实现自旋锁。不过并不推荐各位在实践中使用它,具体可参见 Linus Torvalds 的文章。其中有一段话说得很直接:
- 我再说一遍:不要在用户空间中使用自旋锁,除非你真的知道自己在做什么。请注意,你知道自己在做什么的可能性基本上为零。
I repeat: do not use spinlocks in user space, unless you actually know what you're doing. And be aware that the likelihood that you know what you are doing is basically nil.然后就是推荐使用
std::mutex
、pthread_mutex
,比自旋好的多。
std::atomic_flag
的局限性太强,甚至不能当普通的 bool 标志那样使用。一般最好使用 std::atomic<bool>
,下节,我们来使用它。
std::atomic<bool>
std::atomic<bool>
是最基本的整数原子类型 ,它相较于 std::atomic_flag
提供了更加完善的布尔标志。虽然同样不可复制不可移动,但可以使用非原子的 bool 类型进行构造,初始化为 true 或 false,并且能从非原子的 bool 对象赋值给 std::atomic<bool>
:
std::atomic<bool> b{ true };
+b = false;
不过这个 operator=
不同于通常情况,赋值操作 b = false
返回一个普通的 bool
值。
这个行为不仅仅适用于
std::atomic<bool>
,而是适用于所有std::atomic
类型。
如果原子变量的赋值操作返回了一个引用,那么依赖这个结果的代码需要显式地进行加载(load),以确保数据的正确性。例如:
std::atomic<bool>b {true};
+auto& ref = (b = false); // 假设返回 atomic 引用
+bool flag = ref.load(); // 那就必须显式调用 load() 加载
通过返回非原子值进行赋值,可以避免多余的加载(load)过程,得到实际存储的值。
std::atomic<bool> b{ true };
+bool new_value = (b = false); // new_value 将是 false
使用 store
原子的替换当前对象的值,远好于 std::atomic_flag
的 clear()
。test_and_set()
也可以换为更加通用常见的 exchange
,它可以原子的使用新的值替换已经存储的值,并返回旧值。
获取 std::atomic<bool>
的值有两种方式,调用 load()
函数,或者隐式转换。
store
是一个存储操作、load
是一个加载操作、exchange
是一个“读-改-写”操作:
std::atomic<bool> b;
+bool x = b.load(std::memory_order_acquire);
+b.store(true);
+x = b.exchange(false, std::memory_order_acq_rel);
std::atomic<bool>
提供多个“读-改-写”的操作,exchange 只是其中之一。它还提供了一种存储方式:当前值与预期一致时,存储新值。
这种操作叫做“比较/交换”,它的形式表现为 compare_exchange_weak()
和 compare_exchang_strong()
compare_exchange_weak:尝试将原子对象的当前值与预期值进行比较[1],如果相等则将其更新为新值并返回 true
;否则,将原子对象的值加载进 expected(进行加载操作)并返回 false
。此操作可能会由于某些硬件的特性而出现假失败[2],需要在循环中重试。
std::atomic<bool> flag{ false };
+bool expected = false;
+
+while (!flag.compare_exchange_weak(expected, true));
运行测试。
返回 false
即代表出现了假失败,因此需要在循环中重试。。
compare_exchange_strong:类似于 compare_exchange_weak
,但不会出现假失败,因此不需要重试。适用于需要确保操作成功的场合。
std::atomic<bool> flag{ false };
+bool expected = false;
+
+void try_set_flag() {
+ // 尝试将 flag 设置为 true,如果当前值为 false
+ if (flag.compare_exchange_strong(expected, true)) {
+ std::cout << "flag 为 false,设为 true。\\n";
+ }
+ else {
+ std::cout << "flag 为 true, expected 设为 true。\\n";
+ }
+}
运行测试。
假设有两个线程运行 try_set_flag
函数,那么第一个线程调用 compare_exchange_strong
将原子对象 flag
设置为 true
。第二个线程调用 compare_exchange_strong
,当前原子对象的值为 true
,而 expected
为 false
,不相等,将原子对象的值设置给 expected
。此时 flag
与 expected
均为 true
。
与 exchange
的另一个不同是,compare_exchange_weak
和 compare_exchange_strong
允许指定成功和失败情况下的内存序。这意味着你可以根据成功或失败的情况,为原子操作指定不同的内存序。
std::atomic<bool> data{ false };
+bool expected = false;
+
+// 成功时的内存序为 memory_order_release,失败时的内存序为 memory_order_acquire
+if (data.compare_exchange_weak(expected, true,
+ std::memory_order_release, std::memory_order_acquire)) {
+ // 操作成功
+}
+else {
+ // 操作失败
+}
另一个简单的原子类型是特化的原子指针,即:std::atomic<T*>
,下一节我们来看看它是如何工作的。
std::atomic<T*>
std::atomic<T*>
是一个原子指针类型,T
是指针所指向的对象类型。操作是针对 T
类型的指针进行的。虽然 std::atomic<T*>
不能被拷贝和移动,但它可以通过符合类型的指针进行构造和赋值。
std::atomic<T*>
拥有以下成员函数:
load()
:以原子方式读取指针值。store()
:以原子方式存储指针值。exchange()
:以原子方式交换指针值。compare_exchange_weak()
和 compare_exchange_strong()
:以原子方式比较并交换指针值。这些函数接受并返回的类型都是 T*。此外,std::atomic<T*>
还提供了以下操作:
fetch_add
:以原子方式增加指针的值。(p.fetch_add(1)
会将指针 p
向前移动一个元素,并返回操作前的指针值)
fetch_sub
:以原子方式减少指针的值。返回操作前的指针值。
operator+=
和 operator-=
:以原子方式增加或减少指针的值。返回操作前的指针值。
这些操作确保在多线程环境下进行安全的指针操作,避免数据竞争和并发问题。
使用示例如下:
struct Foo {};
+
+Foo array[5]{};
+std::atomic<Foo*> p{ array };
+
+// p 加 2,并返回原始值
+Foo* x = p.fetch_add(2);
+assert(x == array);
+assert(p.load() == &array[2]);
+
+// p 减 1,并返回原始值
+x = (p -= 1);
+assert(x == &array[1]);
+assert(p.load() == &array[1]);
+
+// 函数也允许内存序作为给定函数的参数
+p.fetch_add(3, std::memory_order_release);
这个特化十分简单,我们无需过多赘述。
std::atomic<std::shared_ptr>
在前文中,我们多次提到 std::shared_ptr
:
第四章中提到:多个线程能在不同的 shared_ptr 对象上调用所有成员函数[3](包含复制构造函数与复制赋值)而不附加同步,即使这些实例是同一对象的副本且共享所有权也是如此。若多个执行线程访问同一 shared_ptr 对象而不同步,且任一线程使用 shared_ptr 的非 const 成员函数,则将出现数据竞争;
std::atomic<shared_ptr>
能用于避免数据竞争。文档。
一个在互联网上非常热门的八股问题是:std::shared_ptr
是不是线程安全?
显然,它并不是完全线程安全的,尽管在多线程环境中有很大的保证,但这还不够。在 C++20 中,原子模板 std::atomic
引入了一个偏特化版本 std::atomic<std::shared_ptr>
允许用户原子地操纵 shared_ptr
对象。因为它是 std::atomic
的特化版本,即使我们还没有深入讲述它,也能知道它是原子类型,这意味着它的所有操作都是原子操作。
若多个执行线程不同步地同时访问同一 std::shared_ptr
对象,且任何这些访问使用了 shared_ptr 的非 const 成员函数,则将出现数据竞争,除非通过 std::atomic<std::shared_ptr>
的实例进行所有访问。
class Data {
+public:
+ Data(int value = 0) : value_(value) {}
+ int get_value() const { return value_; }
+ void set_value(int new_value) { value_ = new_value; }
+private:
+ int value_;
+};
+
+auto data = std::make_shared<Data>();
+
+void writer(){
+ for (int i = 0; i < 10; ++i) {
+ std::shared_ptr<Data> new_data = std::make_shared<Data>(i);
+ data.swap(new_data); // 调用非 const 成员函数
+ std::this_thread::sleep_for(100ms);
+ }
+}
+
+void reader(){
+ for (int i = 0; i < 10; ++i) {
+ if (data) {
+ std::cout << "读取线程值: " << data->get_value() << std::endl;
+ }
+ else {
+ std::cout << "没有读取到数据" << std::endl;
+ }
+ std::this_thread::sleep_for(100ms);
+ }
+}
+
+int main(){
+ std::thread writer_thread{ writer };
+ std::thread reader_thread{ reader };
+
+ writer_thread.join();
+ reader_thread.join();
+}
运行测试。
以上这段代码是典型的线程不安全,它满足:
多个线程不同步地同时访问同一 std::shared_ptr
对象
任一线程使用 shared_ptr 的非 const 成员函数
那么为什么呢?为什么满足这些概念就是线程不安全呢?为了理解这些概念,首先需要了解 shared_ptr 的内部实现:
shared_ptr 的通常实现只保有两个指针
控制块是一个动态分配的对象,其中包含:
shared_ptr
的数量weak_ptr
的数量控制块是线程安全的,这意味着多个线程可以安全地操作引用计数和访问管理对象,即使这些 shared_ptr
实例是同一对象的副本且共享所有权也是如此。因此,多个线程可以安全地创建、销毁和复制 shared_ptr
对象,因为这些操作仅影响控制块中的引用计数。
然而,shared_ptr
对象实例本身并不是线程安全的。shared_ptr
对象实例包含一个指向控制块的指针和一个指向底层元素的指针。这两个指针的操作在多个线程中并没有同步机制。因此,如果多个线程同时访问同一个 shared_ptr
对象实例并调用非 const
成员函数(如 reset
或 operator=
),这些操作会导致对这些指针的并发修改,进而引发数据竞争。
如果不是同一 shared_ptr 对象,每个线程读写的指针也不是同一个,控制块又是线程安全的,那么自然不存在数据竞争,可以安全的调用所有成员函数。
使用 std::atomic<shared_ptr>
修改:
std::atomic<std::shared_ptr<Data>> data = std::make_shared<Data>();
+
+void writer() {
+ for (int i = 0; i < 10; ++i) {
+ std::shared_ptr<Data> new_data = std::make_shared<Data>(i);
+ data.store(new_data); // 原子地替换所保有的值
+ std::this_thread::sleep_for(10ms);
+ }
+}
+
+void reader() {
+ for (int i = 0; i < 10; ++i) {
+ if (auto sp = data.load()) {
+ std::cout << "读取线程值: " << sp->get_value() << std::endl;
+ }
+ else {
+ std::cout << "没有读取到数据" << std::endl;
+ }
+ std::this_thread::sleep_for(10ms);
+ }
+}
很显然,这是线程安全的,store
是原子操作,而 sp->get_value()
只是个读取操作。
我知道,你肯定会想着:能不能调用 load()
成员函数原子地返回底层的 std::shared_ptr
再调用 swap
成员函数?
可以,但是没有意义,因为 load()
成员函数返回的是底层 std::shared_ptr
的副本,也就是一个临时对象。对这个临时对象调用 swap
并不会改变 data
本身的值,因此这种操作没有实际意义,尽管这不会引发数据竞争(因为是副本)。
由于我们没有对读写操作进行同步,只是确保了操作的线程安全,所以多次运行时可能会看到一些无序的打印,这是正常的。
不过事实上 std::atomic<std::shared_ptr>
的功能相当有限,单看它提供的修改接口(=
、store
、load
、exchang
)就能明白。如果要操作其保护的共享指针指向的资源还是得 load()
获取底层共享指针的副本。此时再进行操作时就得考虑 std::shared_ptr
本身在多线程的支持了。
在使用 std::atomic<std::shared_ptr>
的时候,我们要注意第三章中关于共享数据的一句话:
切勿将受保护数据的指针或引用传递到互斥量作用域之外,不然保护将形同虚设。
原子类型也有类似的问题,以下是一个例子:
std::atomic<std::shared_ptr<int>> ptr = std::make_shared<int>(10);
+*ptr.load() = 100;
load()
成员函数,原子地返回底层共享指针的副本 std::shared_ptr
*get()
,返回了 int&
在第一步时,已经脱离了 std::atomic
的保护,第二步就获取了被保护的数据的引用,第三步进行了修改,这导致了数据竞争。当然了,这种做法非常的愚蠢,只是为了表示,所谓的线程安全,也是要靠开发者的正确使用。
正确的用法如下:
std::atomic<std::shared_ptr<int>> ptr = std::make_shared<int>(10);
+std::atomic_ref<int> ref{ *ptr.load() };
+ref = 100; // 原子地赋 100 给被引用的对象
通过使用 std::atomic_ref
我们得以确保在修改共享资源时保持操作的原子性,从而避免了数据竞争。
最后再来稍微聊一聊提供的 wait
、notify_one
、notify_all
成员函数。这并非是 std::atomic<shared_ptr>
专属,C++20 以后任何 atomic 的特化都拥有这些成员函数,使用起来也都十分的简单,我们这里用一个简单的例子为你展示一下:
std::atomic<std::shared_ptr<int>> ptr = std::make_shared<int>();
+
+void wait_for_wake_up(){
+ std::osyncstream{ std::cout }
+ << "线程 "
+ << std::this_thread::get_id()
+ << " 阻塞,等待更新唤醒\\n";
+
+ // 等待 ptr 变为其它值
+ ptr.wait(ptr.load());
+
+ std::osyncstream{ std::cout }
+ << "线程 "
+ << std::this_thread::get_id()
+ << " 已被唤醒\\n";
+}
+
+void wake_up(){
+ std::this_thread::sleep_for(5s);
+
+ // 更新值并唤醒
+ ptr.store(std::make_shared<int>(10));
+ ptr.notify_one();
+}
事实上我们在前面就用到了不少的内存次序,只不过一直没详细展开讲解。
在开始学习之前,我们需要强调一些基本的认识:
内存次序是非常底层知识:对于普通开发者来说,了解内存次序并非硬性需求。如果您主要关注业务开发,可以直接跳过本节内容。如果您对内存次序感兴趣,则需要注意其复杂性和难以观察的特性,这将使学习过程具有一定挑战性。
内存次序错误的使用难以察觉:即使通过多次(数以万计)运行也难以发现。这是因为许多内存次序问题是由于极端的、少见的情况下的竞争条件引起的,而这些情况很难被重现。此外,即使程序在某些平台上运行正常,也不能保证它在其他平台上也能表现良好,因为不同的 CPU 和编译器可能对内存操作的顺序有不同的处理(例如 x86 架构内存模型:Total Store Order (TSO),是比较严格的内存模型)。因此,开发者必须依赖自己的知识和经验,以及可能的测试和调试技术,来发现和解决内存次序错误。
错误难以被我们观察到的原因其实可以简单的说:
编译器重排:编译器在编译代码时,为了提高性能,可以按照一定规则重新安排代码的执行顺序。例如,可以将不相关的指令重排,使得 CPU 流水线更加高效地执行指令。编译器优化需要遵守一个“如同规则(as-if rule)”,即不可改变可观察的副作用。
CPU 重排:CPU 在运行程序时,也会对指令进行重排,以提高执行效率,减少等待时间。这种重排通常遵循一些硬件层面的优化规则,如内存访问的优化。
你们可能还有疑问:“单线程能不能指令重排?”
CPU 的指令重排必须遵循一定的规则,以确保程序的可观察副作用不受影响。对于单线程程序,CPU 会保证外部行为的一致性。对于多线程程序,需要开发者使用同步原语来显式地控制内存操作的顺序和可见性,确保多线程环境下的正确性。而标准库中提供的原子对象的原子操作,还可以设置内存次序。
那有没有可能:
这也就是前面说的,把 CPU 与编译器当神经病。各位写代码难道还要考虑下面这段,会不会指令重排导致先输出 end
吗?这显然不现实。
print("start"); // 1
+print("end"); // 2
不禁止就是有可能,但是我们无需在乎,就算真的 CPU 将 end 重排到 start 前面了,也得在可观测行为发生前回溯了。所以我一直在强调,这些东西,我们无需在意。
好了,到此,基本认识也就足够了,以上的示例更多的是泛指,知道其表达的意思就好,这些还是简单直接且符合直觉的。
可见 是 C++ 多线程并发编程中的一个重要概念,它描述了一个线程中的数据修改对其他线程的可见程度。具体来说,如果线程 A 对变量 x 进行了修改,那么其他线程 B 是否能够看到线程 A 对 x 的修改,就涉及到可见的问题。
在讨论多线程的内存模型和执行顺序时,虽然经常会提到 CPU 重排、编译器优化、缓存等底层细节,但真正核心的概念是可见,而不是这些底层实现细节。
C++ 标准中的可见:
C++ 标准通过内存序(memory order)来定义如何确保这种可见,而不必直接关心底层的 CPU 和编译器的具体行为。内存序提供了操作之间的顺序关系,确保即使存在 CPU 重排、编译器优化或缓存问题,线程也能正确地看到其他线程对共享数据的修改。
例如,通过使用合适的内存序(如 memory_order_release 和 memory_order_acquire),可以确保线程 A 的写操作在其他线程 B 中是可见的,从而避免数据竞争问题。
总结:
可见 关注的是线程之间的数据一致性,而不是底层的实现细节。
使用 C++ 的内存序机制可以确保数据修改的可见,而不必过多关注具体的 CPU 和编译器行为。
这种描述方式可以帮助更清楚地理解和描述多线程并发编程中如何通过 C++ 标准的内存模型来确保线程之间的数据一致性,而无需太多关注底层细节。
我知道各位肯定有疑问,我们大多数时候写多线程代码都从来没使用过内存序,一般都是互斥量、条件变量等高级同步设施,这没有可见性的问题吗?
没有,这些设施自动确保数据的可见性。例如: std::mutex
的 unlock()
保证:
也就是 unlock()
同步于 lock()
。
“同步于”:操作 A 的完成会确保操作 B 在其之后的执行中,能够看到操作 A 所做的所有修改。
也就是说:
std::mutex
的 unlock()
操作同步于任何随后的 lock()
操作。这意味着,线程在调用 unlock()
时,对共享数据的修改会对之后调用 lock()
的线程可见。std::memory_order
std::memory_order
是一个枚举类型,用来指定原子操作的内存顺序,影响这些操作的行为。
typedef enum memory_order {
+ memory_order_relaxed,
+ memory_order_consume,
+ memory_order_acquire,
+ memory_order_release,
+ memory_order_acq_rel,
+ memory_order_seq_cst
+} memory_order;
+
+// C++20 起则为:
+
+enum class memory_order : /* 未指明 */ {
+ relaxed, consume, acquire, release, acq_rel, seq_cst
+};
+inline constexpr memory_order memory_order_relaxed = memory_order::relaxed;
+inline constexpr memory_order memory_order_consume = memory_order::consume;
+inline constexpr memory_order memory_order_acquire = memory_order::acquire;
+inline constexpr memory_order memory_order_release = memory_order::release;
+inline constexpr memory_order memory_order_acq_rel = memory_order::acq_rel;
+inline constexpr memory_order memory_order_seq_cst = memory_order::seq_cst;
这 6 个常量,每一个常量都表示不同的内存次序。
大体来说我们可以将它们分为三类。
memory_order_relaxed
宽松定序:不是定序约束,仅对此操作要求原子性。memory_order_seq_cst
序列一致定序,这是库中所有原子操作的默认行为,也是最严格的内存次序,是绝对安全的。剩下的就是第三类。
内存模型是软件与实现之间的一种约定契约。它定义了在多线程或并发环境中,如何对内存操作的顺序和一致性进行规范,以确保程序的正确性和可靠性。
C++ 标准为我们定义了 C++ 标准内存模型,使我们能够无需关心底层硬件环境就编写出跨平台的应用程序。不过,了解底层硬件架构的内存模型对扩展知识面和深入理解编程细节也非常有帮助。
最经典与常见的两种 CPU 指令集架构就是:x86
与 ARM
。
x86
架构:是一种复杂指令集计算(CISC)架构,因其强大的性能被广泛应用于桌面电脑、笔记本电脑和服务器中。x86
架构采用的是 TSO(Total Store Order)内存一致性模型,是一种强一致性模型,简化了多线程编程中的内存同步问题(后文中会提到)。
ARM
架构:是一种精简指令集计算(RISC)架构,因其高能效和低功耗特点广泛应用于移动设备、嵌入式系统和物联网设备中。ARM
架构采用的是弱序内存模型(weakly-ordered memory),允许更灵活的内存优化,但这需要程序员使用内存屏障等机制来确保正确性。
这两种架构在设计理念和应用领域上存在显著差异,这也是它们在不同应用场景中表现出色的原因。
如果你从事嵌入式系统或者学术研究等,可能也听说过 RISC-V
架构,它目前在国内的应用也逐渐增多。
RISC-V 是一种开源的精简指令集计算(RISC)架构,旨在提供一种高效、模块化且开放的指令集。与 x86 和 ARM 架构不同,RISC-V 的设计目标是简化指令集,同时保持高度的灵活性和扩展性。它在内存模型方面也有自己独特的特性。
RISC-V 采用的也是弱序内存模型(weakly-ordered memory model),这与 x86 的强一致性模型(TSO)和 ARM 的弱一致性模型有所不同。你可能会有疑问:
ARM
和 RISC-V
都是弱序内存模型,为什么不同?各位一定要区分,这种强弱其实也只是一种分类而已,不同的指令集架构大多都还是有所不同的,并不会完全一样。例如: x86
的 TSO(Total Store Order)是强一致性模型的一种,但并不是所有强一致性模型都是 TSO。
volatile
的关系注: 比较和复制是逐位的(类似 std::memcmp 和 std::memcpy);不使用构造函数、赋值运算符或比较运算符。 ↩︎
注:即使 expected 与原子对象的值相等,表现如同 *this != expected
↩︎
不用感到奇怪,之所以多个线程通过 shared_ptr 的副本可以调用一切成员函数,甚至包括非 const 的成员函数 operator=
、reset
,是因为 shared_ptr
的控制块是线程安全的。 ↩︎
内存模型定义了多线程程序中,读写操作如何在不同线程之间可见,以及这些操作在何种顺序下执行。内存模型确保程序的行为在并发环境下是可预测的。
\\n原子操作即不可分割的操作。系统的所有线程,不可能观察到原子操作完成了一半。
\\n最基础的概念就是如此,这里不再过多赘述,后续还会详细展开内存模型的问题。
\\nint a = 0;\\nvoid f(){\\n ++a;\\n}
\\n既然是“现代” C++ 并发编程教程,怎么能不聊协程呢?
C++20 引入了协程语法,新增了三个用作协程的关键字:co_await
、co_yield
、co_return
。但并未给出标准协程库,协程库在 C++23 被引入。
希望您拥有 gcc14
、clang18
,最新的 MSVC。
',5)]))}const i=o(n,[["render",a],["__file","06协程.html.vue"]]),p=JSON.parse('{"path":"/md/06%E5%8D%8F%E7%A8%8B.html","title":"协程","lang":"zh-CN","frontmatter":{},"headers":[],"git":{"createdTime":1715425898000,"updatedTime":1720963150000,"contributors":[{"name":"mq白","email":"3326284481@qq.com","commits":1},{"name":"归故里","email":"3326284481@qq.com","commits":1}]},"readingTime":{"minutes":0.32,"words":97},"filePathRelative":"md/06协程.md","localizedDate":"2024年5月11日","excerpt":"\\nC++ 20 协程的使用尚不成熟,等待后续更新讲解.....
既然是“现代” C++ 并发编程教程,怎么能不聊协程呢?
\\nC++20 引入了协程语法,新增了三个用作协程的关键字:co_await
、co_yield
、co_return
。但并未给出标准协程库,协程库在 C++23 被引入。
希望您拥有 gcc14
、clang18
,最新的 MSVC。
\\n"}');export{i as comp,p as data}; diff --git a/assets/404.html-C-h4i3I8.js b/assets/404.html-C-h4i3I8.js new file mode 100644 index 00000000..57c0e80a --- /dev/null +++ b/assets/404.html-C-h4i3I8.js @@ -0,0 +1 @@ +import{_ as e}from"./plugin-vue_export-helper-DlAUqK2U.js";import{c as o,a as n,o as r}from"./app-Ca-A-iaw.js";const a={};function l(s,t){return r(),o("div",null,t[0]||(t[0]=[n("p",null,"404 Not Found",-1)]))}const m=e(a,[["render",l],["__file","404.html.vue"]]),p=JSON.parse('{"path":"/404.html","title":"","lang":"zh-CN","frontmatter":{"layout":"NotFound"},"headers":[],"git":{},"readingTime":{"minutes":0.01,"words":3},"filePathRelative":null,"excerpt":"C++ 20 协程的使用尚不成熟,等待后续更新讲解.....
\\n
404 Not Found
\\n"}');export{m as comp,p as data}; diff --git a/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 b/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0acaaff03d4bb7606de02a827aeee338e5a86910 GIT binary patch literal 28076 zcmV)4K+3;&Pew8T0RR910Bx)Q4gdfE0Qryr0ButM0RR9100000000000000000000 z00006U;u_x2rvnp3=s$lgQIMM!gK*P0we>6dJBXK00bZfh;RpzAq;^h8yChW*tQI) zf474tf9UWmvjer;At_qJJ4ObAjRSzte{IG8|DTss#?U6Pq$r5$-28t~$dN6wErwJo za~1SqW}?_^GLyD_B})qv!-NCu+2=w|xZXP?WH@?W-qc{t=*Dc@7G{&*Rr|f2PJS1C zhC(0s6eQ>iMjQ6NMr%a(8W(NUg-6j?jOV&o6a!>CRL6BUiA-uV3!83tjRD8w9Q zTS)(|WV)+(idwaDgvnbaZjk7gd*#am;
z1j?0QvIQdY0!huN%U0DXBJza1_rn0yhhWiSU+_nen>kKH 0xAY3K*FiVnwjWha KNrR
zhN{!5{9&ABbO{-ecmh(_vHVwl5o9KRu61jxX(A<^K2pKZNxXz0kYbZ!Ml`W-VIwD7
znb`Z3KAS7Ld{&wfa=AK5${&oI7vhS8Lde=)Z*xiV@pYMUNB$`4Urww2YA*MtbA`g&
zm-F-0sfabuX^m1CvF(R8#cQ`F^kF<*zp{<_i1~&u);0&0+#yG$o1CEzU? KYy(rp`h0C*-*rIL&|ohVp$XRVDSDNTFXkp_y@GB1KL3UT
zvV=;;5H`mnJF}Gp!Y1#+wI%HxcCP0@$V!{2zwEq|bhVpOdMK03_rjqizgIb2q
zOQA}r+qz>sho84nR)xuNEpAdQb|-W`;ip&m)8#!D;{zkL;(t5TCTLiBge%I`t!y0W
zA_Kr)4_d!3xOQ_?o(SyK$2Asw2